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/.github/scripts/generate-portable-update.mjs b/.github/scripts/generate-portable-update.mjs new file mode 100644 index 0000000..b7980db --- /dev/null +++ b/.github/scripts/generate-portable-update.mjs @@ -0,0 +1,71 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function readOptions(args) { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith("--") || value === undefined) { + throw new Error(`invalid argument near ${name ?? "end of command"}`); + } + options.set(name.slice(2), value); + } + return options; +} + +function required(options, name) { + const value = options.get(name)?.trim(); + if (!value) throw new Error(`--${name} is required`); + return value; +} + +export function generatePortableUpdate({ version, repository, assetName, signature }) { + const normalizedVersion = version.replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) { + throw new Error(`invalid semantic version: ${normalizedVersion}`); + } + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) { + throw new Error(`invalid GitHub repository: ${repository}`); + } + if (!assetName || basename(assetName) !== assetName) { + throw new Error("asset name must be a file name"); + } + if (!signature.trim()) throw new Error("signature is required"); + + return { + version: normalizedVersion, + notes: `Cursor BYOK v${normalizedVersion}`, + pub_date: new Date().toISOString(), + platforms: { + "windows-x86_64": { + signature: signature.trim(), + url: `https://github.com/${repository}/releases/download/v${normalizedVersion}/${encodeURIComponent(assetName)}`, + }, + }, + }; +} + +async function main() { + const options = readOptions(process.argv.slice(2)); + const version = required(options, "version"); + const repository = required(options, "repository"); + const asset = required(options, "asset"); + const signaturePath = resolve(required(options, "signature")); + const output = resolve(required(options, "output")); + const manifest = generatePortableUpdate({ + version, + repository, + assetName: basename(asset), + signature: await readFile(signaturePath, "utf8"), + }); + await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/generate-portable-update.test.mjs b/.github/scripts/generate-portable-update.test.mjs new file mode 100644 index 0000000..1bcd223 --- /dev/null +++ b/.github/scripts/generate-portable-update.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { generatePortableUpdate } from "./generate-portable-update.mjs"; + +test("generates a signed Windows portable updater manifest", () => { + const manifest = generatePortableUpdate({ + version: "v1.2.3-beta.1", + repository: "owner/repository", + assetName: "cursor-byok-1.2.3-beta.1-windows-amd64.zip", + signature: "signed-payload\n", + }); + + assert.equal(manifest.version, "1.2.3-beta.1"); + assert.deepEqual(Object.keys(manifest.platforms), ["windows-x86_64"]); + assert.equal(manifest.platforms["windows-x86_64"].signature, "signed-payload"); + assert.equal( + manifest.platforms["windows-x86_64"].url, + "https://github.com/owner/repository/releases/download/v1.2.3-beta.1/cursor-byok-1.2.3-beta.1-windows-amd64.zip", + ); +}); + +test("rejects invalid inputs", () => { + assert.throws(() => generatePortableUpdate({ + version: "latest", + repository: "owner/repository", + assetName: "update.zip", + signature: "signature", + }), /semantic version/); + assert.throws(() => generatePortableUpdate({ + version: "1.2.3", + repository: "owner/repository", + assetName: "../update.zip", + signature: "signature", + }), /file name/); + assert.throws(() => generatePortableUpdate({ + version: "1.2.3", + repository: "owner/repository", + assetName: "update.zip", + signature: " ", + }), /signature/); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6e73f08 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Rust (Linux) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + # The workspace includes apps/desktop/src-tauri, so even `cargo check` + # needs the same system libraries the release workflow installs. + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run tests + run: cargo test --workspace --all-targets + + frontend: + name: Frontend + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install dependencies + working-directory: apps/desktop + run: npm ci + + - name: Check types and production build + working-directory: apps/desktop + run: npm run check + + desktop-rust: + name: Desktop Rust (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + + - name: Run platform Clippy + run: cargo clippy --package cursor-byok-desktop --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55b8e8..92fd50f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,14 +158,27 @@ jobs: mkdir -p legacy-update tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop - - name: Package legacy Windows updater asset + - name: Package and sign legacy Windows updater asset if: matrix.platform == 'windows-x86_64' shell: pwsh env: VERSION: ${{ needs.prepare.outputs.version }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | New-Item -ItemType Directory -Force legacy-update | Out-Null - Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip" + $asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip" + Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset + Push-Location apps/desktop + npm exec tauri signer sign -- "../../$asset" + Pop-Location + $entries = @(tar -tf $asset) + if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') { + throw "Windows updater archive must contain only cursor-byok-desktop.exe" + } + if (!(Test-Path "$asset.sig")) { + throw "Windows updater archive signature was not generated" + } - name: Package legacy macOS updater asset if: contains(matrix.platform, 'macos') @@ -213,6 +226,20 @@ jobs: --output legacy-update/update.json \ --notes "Cursor BYOK v${VERSION}" + - name: Generate signed Windows portable update manifest + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + asset="cursor-byok-${VERSION}-windows-amd64.zip" + test -f "legacy-update/${asset}" + test -f "legacy-update/${asset}.sig" + node .github/scripts/generate-portable-update.mjs \ + --version "${VERSION}" \ + --repository "${GITHUB_REPOSITORY}" \ + --asset "${asset}" \ + --signature "legacy-update/${asset}.sig" \ + --output legacy-update/portable-latest.json + - name: Normalize Tauri updater download URLs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index e6d0ba7..2e4661a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,10 +1172,11 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "cursor-byok-desktop" -version = "0.1.5" +version = "0.1.6" dependencies = [ "axum", "cursor-server", + "libc", "rfd", "serde", "serde_json", @@ -1187,12 +1188,15 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "tokio", "tokio-util", "tracing", "tracing-appender", "tracing-subscriber", "url", + "windows-sys 0.61.2", + "zip", ] [[package]] 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/package-lock.json b/apps/desktop/package-lock.json index f0b9ab5..cac9448 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-byok-desktop", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-byok-desktop", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b691673..50a1009 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cursor-byok-desktop", - "version": "0.1.5", + "version": "0.1.6", "description": "Cursor BYOK desktop management application", "type": "module", "scripts": { @@ -8,7 +8,7 @@ "dev": "vite", "typecheck": "tsc --noEmit", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", - "i18n:scan": "STATIC_I18N_SCAN=true vite build", + "i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build", "build": "vite build", "build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts", "check": "npm run typecheck && npm run typecheck:node && npm run build", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 403b3a3..10c01dd 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "cursor-byok-desktop" -version = "0.1.5" +version = "0.1.6" edition = "2021" publish = false [lib] name = "cursor_byok_desktop" -crate-type = ["rlib"] +crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] axum = "0.8" cursor-server = { path = "../../../server" } +libc = "0.2" rfd = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -24,9 +25,14 @@ tauri-plugin-opener = "2" tauri-plugin-autostart = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" +tempfile = "3" tokio = { version = "1", features = ["time"] } tokio-util = "0.7" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" +zip = { version = "4", default-features = false, features = ["deflate"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index da09dc1..dcb6da1 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,5 +1,9 @@ fn main() { - let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]); + let manifest = tauri_build::AppManifest::new().commands(&[ + "open_terminal_with_command", + "check_portable_update", + "install_portable_update", + ]); tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) .expect("failed to build Tauri application") } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 1954d26..c34b30e 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,8 @@ "core:window:allow-close", "core:app:allow-set-dock-visibility", "allow-open-terminal-with-command", + "allow-check-portable-update", + "allow-install-portable-update", "clipboard-manager:allow-write-text", "autostart:default", "process:allow-restart", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml new file mode 100644 index 0000000..aea0675 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-check-portable-update" +description = "Enables the check_portable_update command without any pre-configured scope." +commands.allow = ["check_portable_update"] + +[[permission]] +identifier = "deny-check-portable-update" +description = "Denies the check_portable_update command without any pre-configured scope." +commands.deny = ["check_portable_update"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml new file mode 100644 index 0000000..edf77fb --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-install-portable-update" +description = "Enables the install_portable_update command without any pre-configured scope." +commands.allow = ["install_portable_update"] + +[[permission]] +identifier = "deny-install-portable-update" +description = "Denies the install_portable_update command without any pre-configured scope." +commands.deny = ["install_portable_update"] diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index 31f2d85..c700bea 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -149,6 +149,23 @@ pub fn run() -> ExitCode { return ExitCode::FAILURE; } }; + #[cfg(unix)] + { + let open_file_limit = match crate::resource_limits::raise_open_file_limit() { + Ok(limit) => limit, + Err(error) => { + diagnostics.report_fatal(&error); + return ExitCode::FAILURE; + } + }; + tracing::info!( + requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT, + previous = open_file_limit.previous, + effective = open_file_limit.effective, + hard = open_file_limit.hard, + "open file limit configured" + ); + } tracing::info!( version = env!("CARGO_PKG_VERSION"), os = std::env::consts::OS, @@ -160,7 +177,11 @@ pub fn run() -> ExitCode { let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG); let app = tauri::Builder::default() - .invoke_handler(tauri::generate_handler![open_terminal_with_command]) + .invoke_handler(tauri::generate_handler![ + open_terminal_with_command, + crate::update::check_portable_update, + crate::update::install_portable_update, + ]) .plugin(tauri_plugin_single_instance::init(|app, args, _| { if !args.iter().any(|arg| arg == AUTOSTART_ARG) { tray::show_main_window(app); @@ -228,6 +249,7 @@ pub fn run() -> ExitCode { window.set_focus()?; } tray::create(app)?; + crate::update::signal_ready_if_requested()?; Ok(()) }) .build(tauri::generate_context!()); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ec2df31..cef6375 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,7 +1,14 @@ mod desktop; #[cfg(not(dev))] mod frontend; +mod resource_limits; mod startup; mod tray; +mod update; -pub use desktop::run; +pub fn run() -> std::process::ExitCode { + if let Some(exit_code) = update::run_replacement_if_requested() { + return exit_code; + } + desktop::run() +} diff --git a/apps/desktop/src-tauri/src/resource_limits.rs b/apps/desktop/src-tauri/src/resource_limits.rs new file mode 100644 index 0000000..9be98b4 --- /dev/null +++ b/apps/desktop/src-tauri/src/resource_limits.rs @@ -0,0 +1,56 @@ +//! Configures process resource limits before the desktop runtime starts. + +#[cfg(unix)] +use std::io; + +#[cfg(unix)] +pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536; + +#[cfg(unix)] +pub(crate) struct OpenFileLimit { + pub(crate) previous: u64, + pub(crate) effective: u64, + pub(crate) hard: u64, +} + +#[cfg(unix)] +pub(crate) fn raise_open_file_limit() -> io::Result { + let mut limits = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `limits` points to writable memory for one `rlimit` value. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) } != 0 { + return Err(io::Error::last_os_error()); + } + + let previous = limits.rlim_cur; + let target = limits + .rlim_max + .min(REQUESTED_OPEN_FILE_LIMIT as libc::rlim_t); + if previous < target { + let requested = libc::rlimit { + rlim_cur: target, + rlim_max: limits.rlim_max, + }; + // SAFETY: `requested` is a valid `rlimit` value and does not raise the hard limit. + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &requested) } != 0 { + return Err(io::Error::last_os_error()); + } + } + + let mut effective = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: `effective` points to writable memory for one `rlimit` value. + if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut effective) } != 0 { + return Err(io::Error::last_os_error()); + } + + Ok(OpenFileLimit { + previous, + effective: effective.rlim_cur, + hard: effective.rlim_max, + }) +} diff --git a/apps/desktop/src-tauri/src/update/mod.rs b/apps/desktop/src-tauri/src/update/mod.rs new file mode 100644 index 0000000..84fae7a --- /dev/null +++ b/apps/desktop/src-tauri/src/update/mod.rs @@ -0,0 +1,282 @@ +use std::process::ExitCode; + +#[cfg(target_os = "windows")] +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::PathBuf, + process::Command, +}; +#[cfg(any(target_os = "windows", test))] +use std::{ + io::{Cursor, Read}, + path::Path, +}; + +use serde::Serialize; +use tauri::AppHandle; + +#[cfg(target_os = "windows")] +use tauri_plugin_updater::UpdaterExt; + +#[cfg(target_os = "windows")] +mod replacement; + +#[cfg(target_os = "windows")] +const PORTABLE_UPDATE_ENDPOINT: &str = + "https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json"; +#[cfg(any(target_os = "windows", test))] +const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PortableUpdateInfo { + version: String, +} + +pub fn run_replacement_if_requested() -> Option { + #[cfg(target_os = "windows")] + { + match replacement::request_from_args() { + Ok(Some(request)) => return Some(replacement::run(request)), + Ok(None) => {} + Err(error) => { + eprintln!("invalid portable update replacement request: {error}"); + return Some(ExitCode::FAILURE); + } + } + } + None +} + +pub(crate) fn signal_ready_if_requested() -> std::io::Result<()> { + #[cfg(target_os = "windows")] + if let Some(path) = replacement::ready_marker_from_args() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, b"ready")?; + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn check_portable_update( + app: AppHandle, +) -> Result, String> { + #[cfg(target_os = "windows")] + { + let update = portable_update(&app).await?; + Ok(update.map(|update| PortableUpdateInfo { + version: update.version, + })) + } + #[cfg(not(target_os = "windows"))] + { + let _ = app; + Err("portable updates are only supported on Windows".into()) + } +} + +#[tauri::command] +pub(crate) async fn install_portable_update( + app: AppHandle, + expected_version: String, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + let update = portable_update(&app) + .await? + .ok_or_else(|| "the selected update is no longer available".to_string())?; + if update.version != expected_version { + return Err(format!( + "available update changed from {expected_version} to {}", + update.version + )); + } + + let target = std::env::current_exe() + .map_err(|error| format!("failed to locate the running executable: {error}"))?; + ensure_target_writable(&target) + .map_err(|error| format!("the application directory is not writable: {error}"))?; + + let bytes = update + .download(|_, _| {}, || {}) + .await + .map_err(|error| format!("failed to download or verify the update: {error}"))?; + let payload = extract_windows_payload(&bytes) + .map_err(|error| format!("invalid Windows update archive: {error}"))?; + let staged = stage_payload(&target, &payload) + .map_err(|error| format!("failed to stage the update: {error}"))?; + + let handshake = staged.with_extension("started"); + let _ = fs::remove_file(&handshake); + let mut replacement = Command::new(&staged) + .arg("--apply-portable-update") + .arg("--update-target") + .arg(&target) + .arg("--update-wait-pid") + .arg(std::process::id().to_string()) + .arg("--update-handshake") + .arg(&handshake) + .spawn() + .map_err(|error| format!("failed to start the update replacement process: {error}"))?; + wait_for_replacement_start(&mut replacement, &handshake).await?; + + app.exit(0); + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, expected_version); + Err("portable updates are only supported on Windows".into()) + } +} + +#[cfg(target_os = "windows")] +async fn portable_update(app: &AppHandle) -> Result, String> { + let endpoint = PORTABLE_UPDATE_ENDPOINT + .parse() + .map_err(|error| format!("invalid portable update endpoint: {error}"))?; + let updater = app + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|error| format!("failed to configure the updater: {error}"))? + .build() + .map_err(|error| format!("failed to initialize the updater: {error}"))?; + updater + .check() + .await + .map_err(|error| format!("failed to check for updates: {error}")) +} + +#[cfg(target_os = "windows")] +async fn wait_for_replacement_start( + child: &mut std::process::Child, + handshake: &Path, +) -> Result<(), String> { + let wait = async { + loop { + if handshake.is_file() { + return Ok(()); + } + if let Some(status) = child + .try_wait() + .map_err(|error| format!("failed to inspect replacement process: {error}"))? + { + return Err(format!( + "update replacement process exited before it was ready: {status}" + )); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }; + let result = match tokio::time::timeout(std::time::Duration::from_secs(5), wait).await { + Ok(result) => result, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("update replacement process did not become ready".into()); + } + }; + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + result +} + +#[cfg(target_os = "windows")] +fn ensure_target_writable(target: &Path) -> std::io::Result<()> { + let parent = target + .parent() + .ok_or_else(|| std::io::Error::other("application executable has no parent directory"))?; + let probe = parent.join(format!( + ".cursor-byok-update-write-test-{}", + std::process::id() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe)?; + file.write_all(b"test")?; + drop(file); + fs::remove_file(probe) +} + +#[cfg(any(target_os = "windows", test))] +fn extract_windows_payload(bytes: &[u8]) -> Result, String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("failed to open ZIP: {error}"))?; + if archive.len() != 1 { + return Err("archive must contain exactly one file".into()); + } + let mut entry = archive + .by_index(0) + .map_err(|error| format!("failed to read ZIP entry: {error}"))?; + let name = Path::new(entry.name()) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "archive entry has an invalid file name".to_string())?; + if name != WINDOWS_PAYLOAD_NAME || entry.is_dir() { + return Err(format!( + "expected {WINDOWS_PAYLOAD_NAME}, found {}", + entry.name() + )); + } + let mut payload = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut payload) + .map_err(|error| format!("failed to extract executable: {error}"))?; + if payload.len() < 2 || &payload[..2] != b"MZ" { + return Err("payload is not a Windows executable".into()); + } + Ok(payload) +} + +#[cfg(target_os = "windows")] +fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result { + let directory = tempfile::Builder::new() + .prefix("cursor-byok-portable-update-") + .tempdir()?; + let name = target + .file_name() + .ok_or_else(|| std::io::Error::other("application executable has no file name"))?; + let path = directory.path().join(name); + fs::write(&path, payload)?; + let _ = directory.keep(); + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn update_zip(name: &str, payload: &[u8]) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + { + let mut archive = zip::ZipWriter::new(&mut bytes); + archive + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + archive.write_all(payload).unwrap(); + archive.finish().unwrap(); + } + bytes.into_inner() + } + + #[test] + fn extracts_the_single_expected_windows_executable() { + let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload"); + assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload"); + } + + #[test] + fn rejects_unexpected_or_non_executable_payloads() { + let wrong_name = update_zip("other.exe", b"MZpayload"); + assert!(extract_windows_payload(&wrong_name).is_err()); + let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable"); + assert!(extract_windows_payload(&wrong_content).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/update/replacement.rs b/apps/desktop/src-tauri/src/update/replacement.rs new file mode 100644 index 0000000..cf2cb78 --- /dev/null +++ b/apps/desktop/src-tauri/src/update/replacement.rs @@ -0,0 +1,296 @@ +use std::{ + ffi::{OsStr, OsString}, + fs, io, + path::{Path, PathBuf}, + process::{Child, Command, ExitCode}, + thread, + time::{Duration, Instant}, +}; + +const APPLY_ARG: &str = "--apply-portable-update"; +const TARGET_ARG: &str = "--update-target"; +const PID_ARG: &str = "--update-wait-pid"; +const HANDSHAKE_ARG: &str = "--update-handshake"; +pub(super) const READY_ARG: &str = "--portable-update-ready"; +const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30); +const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + +pub(super) struct ReplacementRequest { + target: PathBuf, + pid: u32, + handshake: PathBuf, +} + +pub(super) fn request_from_args() -> Result, String> { + let args = std::env::args_os().collect::>(); + if !args.iter().any(|arg| arg == APPLY_ARG) { + return Ok(None); + } + let target = PathBuf::from( + argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?, + ); + let pid = argument_value(&args, PID_ARG) + .ok_or_else(|| format!("{PID_ARG} is required"))? + .to_string_lossy() + .parse::() + .map_err(|error| format!("invalid {PID_ARG}: {error}"))?; + let handshake = PathBuf::from( + argument_value(&args, HANDSHAKE_ARG) + .ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?, + ); + validate_target(&target).map_err(|error| error.to_string())?; + Ok(Some(ReplacementRequest { + target, + pid, + handshake, + })) +} + +pub(super) fn ready_marker_from_args() -> Option { + let args = std::env::args_os().collect::>(); + argument_value(&args, READY_ARG).map(PathBuf::from) +} + +fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> { + args.iter() + .position(|arg| arg == name) + .and_then(|index| args.get(index + 1)) + .map(OsString::as_os_str) +} + +fn validate_target(target: &Path) -> io::Result<()> { + if !target.is_absolute() || !target.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "update target must be an existing absolute file", + )); + } + let source_name = std::env::current_exe()? + .file_name() + .map(OsStr::to_os_string) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?; + if target.file_name() != Some(source_name.as_os_str()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "update target file name does not match the updater", + )); + } + Ok(()) +} + +pub(super) fn run(request: ReplacementRequest) -> ExitCode { + match run_inner(request) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("portable update replacement failed: {error}"); + ExitCode::FAILURE + } + } +} + +fn run_inner(request: ReplacementRequest) -> io::Result<()> { + wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?; + remove_file_if_exists(&request.handshake)?; + let source = std::env::current_exe()?; + let backup = backup_path(&request.target); + let ready = source.with_extension("ready"); + remove_file_if_exists(&ready)?; + + if let Err(error) = install_staged(&source, &request.target, &backup) { + relaunch(&request.target); + return Err(error); + } + let mut child = match Command::new(&request.target) + .arg(READY_ARG) + .arg(&ready) + .spawn() + { + Ok(child) => child, + Err(error) => { + restore_backup(&request.target, &backup)?; + relaunch(&request.target); + return Err(error); + } + }; + + match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) { + Ok(()) => { + let _ = fs::remove_file(&backup); + let _ = fs::remove_file(&ready); + Ok(()) + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + restore_backup(&request.target, &backup)?; + relaunch(&request.target); + Err(error) + } + } +} + +fn relaunch(target: &Path) { + if target.is_file() { + let _ = Command::new(target).spawn(); + } +} + +fn remove_file_if_exists(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn backup_path(target: &Path) -> PathBuf { + path_with_suffix(target, ".old") +} + +fn pending_path(target: &Path) -> PathBuf { + path_with_suffix(target, ".new") +} + +fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf { + let mut name = target.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} + +fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> { + let pending = pending_path(target); + remove_file_if_exists(&pending)?; + fs::copy(source, &pending)?; + + let result = activate_pending(&pending, target, backup); + if result.is_err() { + let _ = fs::remove_file(&pending); + } + result +} + +fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> { + remove_file_if_exists(backup)?; + fs::rename(target, backup)?; + if let Err(error) = fs::rename(pending, target) { + if let Err(restore_error) = restore_backup(target, backup) { + return Err(io::Error::other(format!( + "failed to install update ({error}) and restore the original executable ({restore_error})" + ))); + } + return Err(error); + } + Ok(()) +} + +fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> { + remove_file_if_exists(target)?; + fs::rename(backup, target) +} + +fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> { + let deadline = Instant::now() + timeout; + loop { + if marker.is_file() { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + return Err(io::Error::other(format!( + "updated application exited before startup completed: {status}" + ))); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "updated application did not report a successful startup", + )); + } + thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(windows)] +fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> { + use windows_sys::Win32::{ + Foundation::{ + CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + System::Threading::{OpenProcess, WaitForSingleObject}, + }; + + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) }; + if handle.is_null() { + let error = unsafe { GetLastError() }; + return if error == ERROR_INVALID_PARAMETER { + fs::write(handshake, b"started") + } else { + Err(io::Error::from_raw_os_error(error as i32)) + }; + } + if let Err(error) = fs::write(handshake, b"started") { + unsafe { CloseHandle(handle) }; + return Err(error); + } + let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32; + let result = unsafe { WaitForSingleObject(handle, milliseconds) }; + unsafe { CloseHandle(handle) }; + match result { + WAIT_OBJECT_0 => Ok(()), + WAIT_TIMEOUT => Err(io::Error::new( + io::ErrorKind::TimedOut, + "running application did not exit before the update timeout", + )), + _ => Err(io::Error::last_os_error()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staged_file_can_be_restored() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source.exe"); + let target = directory.path().join("target.exe"); + let backup = backup_path(&target); + fs::write(&source, b"new").unwrap(); + fs::write(&target, b"old").unwrap(); + + install_staged(&source, &target, &backup).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read(&backup).unwrap(), b"old"); + + restore_backup(&target, &backup).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + } + + #[test] + fn missing_pending_file_restores_original_after_backup() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.exe"); + let pending = pending_path(&target); + let backup = backup_path(&target); + fs::write(&target, b"old").unwrap(); + + assert!(activate_pending(&pending, &target, &backup).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + } + + #[test] + fn missing_source_preserves_original_file() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("missing.exe"); + let target = directory.path().join("target.exe"); + let backup = backup_path(&target); + fs::write(&target, b"old").unwrap(); + + assert!(install_staged(&source, &target, &backup).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + assert!(!pending_path(&target).exists()); + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 7d6f768..3ab75d8 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cursor BYOK", - "version": "0.1.5", + "version": "0.1.6", "identifier": "dev.cursorbyok.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -12,7 +12,7 @@ "app": { "windows": [], "security": { - "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*", + "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*", "dangerousDisableAssetCspModification": [ "style-src" ] 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 5577482..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, }; @@ -86,7 +87,7 @@ const harnessStatus: CursorHarnessStatus = { let detailed = true; let portSettings = { proxy_port: 0, service_port: 0 }; let proxySettings: ProxySettings = { - mode: "system", + mode: "default", address: "", auth_enabled: false, username: "", @@ -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..ae6973f 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -1,15 +1,17 @@ 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"; import { PageContent } from "../../shell/layout/PageContent"; import type { VirtualPageSection } from "../../shell/layout/VirtualPage"; -import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/OverviewTimeRangeFilter"; +import { OverviewTimeRangeFilter, type OverviewRangePreset, type QuickPreset } from "./overview/OverviewTimeRangeFilter"; 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 }; @@ -25,13 +27,18 @@ function presetRange(preset: Exclude, now = new D start.setMonth(start.getMonth() - 1); return { startMs: start.getTime(), endMs }; } - const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000; + const duration = preset === "ten-minutes" ? 10 * 60_000 + : preset === "hour" ? 60 * 60_000 + : 7 * 24 * 60 * 60_000; return { startMs: now.getTime() - duration, endMs }; } export function HomePage() { const { overview, busy, models, plugins } = useAppStore(); + const { locale } = useI18n(); const [preset, setPreset] = useState("month"); + const [quick, setQuick] = useState(null); + const [fourHourBucket, setFourHourBucket] = useState(undefined); const [customRange, setCustomRange] = useState(null); const [customOpen, setCustomOpen] = useState(false); const [customStart, setCustomStart] = useState(""); @@ -50,13 +57,14 @@ export function HomePage() { void api.overview({ ...selectedRange, modelHashes: appliedModels, + bucketMs: fourHourBucket, }).then((next) => { if (active) setRangeOverview(next); }).finally(() => { if (active) setRangeBusy(false); }); return () => { active = false; }; - }, [preset, customRange, overview, refreshVersion, appliedModels]); + }, [preset, customRange, overview, refreshVersion, appliedModels, fourHourBucket]); const filteredOverview = rangeOverview ?? overview; const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({ @@ -93,9 +101,26 @@ export function HomePage() { if (startMs === null || endMs === null || startMs >= endMs) return; setCustomRange({ startMs, endMs }); setAppliedModels(selectedModels); + setQuick(null); + setFourHourBucket(undefined); setPreset("custom"); setCustomOpen(false); }; + const selectQuick = (durationMs: number, bucketMs?: number) => { + const endMs = Date.now(); + setCustomRange({ startMs: endMs - durationMs, endMs }); + setAppliedModels(selectedModels); + setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours"); + setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined); + setPreset("custom"); + setCustomOpen(false); + }; + const selectPreset = (value: Exclude) => { + setPreset(value); + setQuick(null); + setFourHourBucket(undefined); + setCustomOpen(false); + }; const refresh = async () => { await appStore.refresh(); setRefreshVersion((version) => version + 1); @@ -105,13 +130,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[] = [ { @@ -138,13 +168,16 @@ export function HomePage() { return <> { setPreset(value); setCustomOpen(false); }} + onSelect={selectPreset} + onQuickSelect={selectQuick} onCustomOpenChange={openCustom} onCustomStartChange={setCustomStart} onCustomEndChange={setCustomEnd} diff --git a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss index 6278333..13a3b45 100644 --- a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss +++ b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss @@ -76,3 +76,50 @@ justify-content: flex-end; gap: 6px; } + +.quickPresets { + display: flex; + gap: 6px; + + button { + height: 26px; + padding: 0 10px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-button-secondaryBackground, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-sideBar-border); + border-radius: 4px; + font-size: type.$font-size-xs; + white-space: nowrap; + + &:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); } + &[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; } + } +} + +.granularityMenu { + position: fixed; + z-index: 15000; + min-width: 96px; + padding: 4px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 6px; + box-shadow: var(--oa-dropdown-shadow); + + button { + width: 100%; + min-height: 28px; + display: block; + padding: 4px 8px; + color: var(--vscode-dropdown-foreground); + text-align: left; + background: transparent; + border: 0; + border-radius: 4px; + font-size: type.$font-size-xs; + white-space: nowrap; + + &:hover { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } + &[aria-pressed="true"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } + } +} diff --git a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx index 69eab62..f76c761 100644 --- a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx +++ b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx @@ -4,22 +4,26 @@ 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"; export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom"; +export type QuickPreset = "four-hours" | "twenty-four-hours"; -export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: { +export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: { value: OverviewRangePreset; + quick: QuickPreset | null; + fourHourBucket: number | undefined; customOpen: boolean; customStart: string; customEnd: string; - modelOptions: MultiSelectOption[]; + modelOptions: ModelSelectOption[]; selectedModels: string[]; busy: boolean; onSelect: (value: Exclude) => void; + onQuickSelect: (durationMs: number, bucketMs?: number) => void; onCustomOpenChange: (open: boolean) => void; onCustomStartChange: (value: string) => void; onCustomEndChange: (value: string) => void; @@ -34,10 +38,25 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom { value: "week", label: t("近一周") }, { value: "month", label: t("近一个月") }, ]; + const quickPresets: Array<{ value: QuickPreset; label: string }> = [ + { value: "four-hours", label: t("近4小时") }, + { value: "twenty-four-hours", label: t("近24小时") }, + ]; + const granularityOptions = [ + { bucketMs: 60_000, label: t("1分钟") }, + { bucketMs: 15 * 60_000, label: t("15分钟") }, + { bucketMs: 30 * 60_000, label: t("30分钟") }, + { bucketMs: 60 * 60_000, label: t("1小时") }, + ]; const customButton = useRef(null); const popover = useRef(null); + const fourHourButton = useRef(null); + const granularityMenu = useRef(null); const popoverId = useId(); + const granularityMenuId = useId(); const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 }); + const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 }); + const [granularityOpen, setGranularityOpen] = useState(false); useLayoutEffect(() => { if (!customOpen || !customButton.current || !popover.current) return; @@ -53,11 +72,28 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [customOpen]); + useLayoutEffect(() => { + if (!granularityOpen || !fourHourButton.current || !granularityMenu.current) return; + return autoUpdate(fourHourButton.current, granularityMenu.current, () => + void computePosition(fourHourButton.current!, granularityMenu.current!, { + placement: "right-start", + middleware: [offset(4), flip({ padding: 10 }), shift({ padding: 10 })], + }).then(({ x, y }) => setMenuPosition({ left: x, top: y }))); + }, [granularityOpen]); + + useEffect(() => { + if (!customOpen) setGranularityOpen(false); + }, [customOpen]); + useEffect(() => { if (!customOpen) return; const closeOutside = (event: PointerEvent) => { const target = event.target as Node; - if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false); + if ( + !customButton.current?.contains(target) + && !popover.current?.contains(target) + && !granularityMenu.current?.contains(target) + ) onCustomOpenChange(false); }; document.addEventListener("pointerdown", closeOutside); return () => document.removeEventListener("pointerdown", closeOutside); @@ -66,6 +102,10 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom const parsedStart = parseTimeInput(customStart); const parsedEnd = parseTimeInput(customEnd); const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd; + const closeGranularity = () => { + setGranularityOpen(false); + fourHourButton.current?.focus(); + }; return
@@ -98,18 +138,70 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); + if (granularityOpen) { + closeGranularity(); + return; + } onCustomOpenChange(false); customButton.current?.focus(); } }} > +
+ {quickPresets.map((preset) => preset.value === "four-hours" + ? + : )} +
-
+
, document.body)} + {granularityOpen && customOpen && createPortal(, document.body)}
; } 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 && }
setModel({ api_key: event.target.value })} /> - {discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> + void discoverModels()}>{discovering ? t("获取中…") : t("获取模型")}} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> setModel({ display_name: event.target.value })} /> setModel({ tooltip_data: event.target.value })} /> diff --git a/apps/desktop/src/features/models/CursorSettings.module.scss b/apps/desktop/src/features/models/CursorSettings.module.scss index 0624aad..e957a55 100644 --- a/apps/desktop/src/features/models/CursorSettings.module.scss +++ b/apps/desktop/src/features/models/CursorSettings.module.scss @@ -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; diff --git a/apps/desktop/src/features/models/CursorSettingsPage.tsx b/apps/desktop/src/features/models/CursorSettingsPage.tsx index 5497ee6..245d028 100644 --- a/apps/desktop/src/features/models/CursorSettingsPage.tsx +++ b/apps/desktop/src/features/models/CursorSettingsPage.tsx @@ -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(null); const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false); const [deleting, setDeleting] = useState(null); + const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false); const [testingModelHashes, setTestingModelHashes] = useState>(() => new Set()); const [modelTestResults, setModelTestResults] = useState>(() => new Map()); const [savingAndTesting, setSavingAndTesting] = useState(false); @@ -42,6 +44,8 @@ export function CursorSettingsPage() { const [groupSettingsBusy, setGroupSettingsBusy] = useState(false); const activeModelTests = useRef(new Map()); 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 })), @@ -84,8 +88,8 @@ export function CursorSettingsPage() { anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2), }); }; - const discover = async () => { - if (!draft) return; + const discover = async (): Promise => { + if (!draft) return false; setDiscovering(true); try { const custom_headers = parseHeaders(draft.customHeadersText); @@ -97,8 +101,10 @@ export function CursorSettingsPage() { custom_headers, }); setModelOptions([...new Set(result.models)]); + return true; } catch (cause) { message(errorText(cause)); + return false; } finally { setDiscovering(false); } @@ -123,11 +129,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,19 +301,46 @@ 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 && -
- - {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()} /> + } setCaCommand(null)} onConfirm={openCaTerminal}> diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss index d17ef3b..10a3e0b 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss +++ b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss @@ -99,13 +99,11 @@ display: flex; align-items: center; flex-wrap: nowrap; - gap: 6px; + gap: 7px; > button { min-width: 0; flex: 0 1 auto; - padding-left: 8px; - padding-right: 8px; } } diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index 11c8cb7..a1ca992 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { api, getDisabledPluginModelIds, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api"; +import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api"; import { useI18n } from "../../i18n/store"; import { PageContent } from "../../shell/layout/PageContent"; import { appStore, useAppStore } from "../../shared/store/appStore"; @@ -138,17 +138,7 @@ function PluginCard({ plugin, onOpen }: { const [importing, setImporting] = useState(false); const configured = plugin.providers.some((provider) => provider.configured); const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0); - const [disabledModelIds, setDisabledModelIds] = useState>(() => getDisabledPluginModelIds()); - useEffect(() => { - const handleUpdate = () => setDisabledModelIds(getDisabledPluginModelIds()); - window.addEventListener("cursor_plugin_models_changed", handleUpdate); - return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); - }, []); - - const modelCount = plugin.providers.reduce( - (count, provider) => count + provider.models.filter((m) => !disabledModelIds.has(m.id)).length, - 0, - ); + const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0); const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id; const importResource = plugin.resources.find((resource) => resource.import); const exportResource = plugin.resources.find((resource) => resource.resources.length > 0); diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss index 6f2c965..23d1fb2 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss @@ -12,7 +12,7 @@ gap: 10px; padding: 16px; - >span { + > span { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; } @@ -37,10 +37,6 @@ } button { - max-width: 240px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; padding: 6px 10px; color: var(--vscode-foreground); background: var(--vscode-textCodeBlock-background); @@ -101,21 +97,15 @@ gap: 8px; } -.providerCard { - display: flex; - flex-direction: column; - padding: 0; - overflow: hidden; -} - -.providerRow { +.providerRow, +.resourceRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; - >div:first-child { + > div:first-child { min-width: 0; display: flex; flex-direction: column; @@ -128,576 +118,6 @@ } } -.resourceRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px; - - .resourceText { - display: flex; - flex-direction: column; - gap: 4px; - - .resourceHeaderLine { - display: flex; - align-items: center; - gap: 8px; - } - - .resourceDescription { - color: var(--vscode-descriptionForeground); - font-size: type.$font-size-xs; - } - } -} - -.metricsRow { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - -.metricBadge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 7px; - border-radius: 4px; - font-size: 11px; - font-weight: 500; - line-height: 1.2; - transition: all 0.15s ease; - - .metricPrefix { - font-weight: 700; - opacity: 0.9; - } - - .metricValue { - font-variant-numeric: tabular-nums; - } -} - -.claudeMetricBadge { - background: rgba(234, 88, 12, 0.14); - color: #fb923c; - border: 1px solid rgba(234, 88, 12, 0.28); -} - -.geminiMetricBadge { - background: rgba(59, 130, 246, 0.14); - color: #60a5fa; - border: 1px solid rgba(59, 130, 246, 0.28); -} - -.genericMetricBadge { - background: color-mix(in srgb, var(--vscode-badge-background, #333) 40%, transparent); - color: var(--vscode-badge-foreground, #ccc); - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent); -} - -.proBadge { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 1px 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - border-radius: 4px; - background: linear-gradient(135deg, rgba(234, 88, 12, 0.25), rgba(249, 115, 22, 0.15)); - color: #fb923c; - border: 1px solid rgba(249, 115, 22, 0.4); - box-shadow: 0 0 6px rgba(249, 115, 22, 0.15); -} - -.freeBadge { - display: inline-flex; - align-items: center; - padding: 1px 5px; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - border-radius: 4px; - background: color-mix(in srgb, var(--vscode-badge-background, #333) 30%, transparent); - color: var(--vscode-descriptionForeground, #888); - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent); -} - -.providerActions { - display: flex; - align-items: center; - gap: 8px; -} - -.modelsView { - display: flex; - flex-direction: column; - gap: 12px; -} - -.modelsViewHeader { - display: flex; - align-items: center; - gap: 12px; - padding-bottom: 2px; - - .backButton { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - color: var(--vscode-descriptionForeground); - background: transparent; - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 60%, transparent); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - color: var(--vscode-foreground); - background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); - border-color: var(--vscode-focusBorder, #007fd4); - } - } - - .headerTitle { - display: flex; - align-items: center; - gap: 10px; - - strong { - color: var(--vscode-foreground); - font-size: type.$font-size-base; - font-weight: 600; - } - - span { - color: var(--vscode-descriptionForeground); - font-size: type.$font-size-xs; - } - - .headerCountChip { - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - color: var(--vscode-focusBorder, #007fd4); - background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 14%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 30%, transparent); - } - } -} - -.modelListToolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.searchContainer { - position: relative; - flex: 1 1 auto; - min-width: 0; - - .searchIcon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--vscode-descriptionForeground); - pointer-events: none; - } - - .clearSearchBtn { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - width: 20px; - height: 20px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - color: var(--vscode-descriptionForeground); - background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); - border: none; - border-radius: 50%; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: var(--vscode-foreground); - background: color-mix(in srgb, var(--vscode-foreground) 20%, transparent); - } - } -} - -.modelSearchInput { - width: 100%; - padding: 7px 30px 7px 32px; - font-size: type.$font-size-xs; - color: var(--vscode-foreground); - background: var(--vscode-input-background); - border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border)); - border-radius: 6px; - outline: none; - transition: all 0.2s ease; - box-sizing: border-box; - - &::placeholder { - color: color-mix(in srgb, var(--vscode-descriptionForeground) 70%, transparent); - } - - &:focus { - border-color: var(--vscode-focusBorder, #007fd4); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 20%, transparent); - } -} - -.modelBatchActions { - display: flex; - align-items: center; - gap: 8px; - - .pillButton { - padding: 5px 12px; - font-size: type.$font-size-xs; - font-weight: 500; - color: var(--vscode-foreground); - background: color-mix(in srgb, var(--vscode-button-secondaryBackground, #3a3d41) 60%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent); - border-radius: 999px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: var(--vscode-button-foreground, #fff); - background: var(--vscode-button-secondaryHoverBackground, #45494e); - border-color: color-mix(in srgb, var(--vscode-button-secondaryHoverBackground, #45494e) 50%, transparent); - } - } -} - -.modelsViewList { - display: flex; - flex-direction: column; - gap: 8px; -} - -.emptySearch { - padding: 32px 16px; - text-align: center; - color: var(--vscode-descriptionForeground); - font-size: type.$font-size-xs; -} - -.modelItem { - display: flex; - align-items: center; - gap: 12px; - padding: 9px 14px; - background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, var(--vscode-editor-background)); - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 70%, transparent); - border-radius: 8px; - font-size: type.$font-size-xs; - cursor: pointer; - user-select: none; - position: relative; - transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - transform: translateY(-1px); - border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 50%, transparent); - box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18); - background: color-mix(in srgb, var(--vscode-sideBar-background) 80%, var(--vscode-focusBorder, #007fd4) 6%); - } -} - -.modelItemActive { - border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 55%, transparent); - background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 7%, var(--vscode-editor-background)); -} - -.modelItemDisabled { - opacity: 0.45; - background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent); - border-color: color-mix(in srgb, var(--vscode-sideBar-border) 40%, transparent); - - &:hover { - opacity: 0.8; - transform: none; - } -} - -.modelCheckbox { - flex: 0 0 auto; - width: 16px; - height: 16px; - margin: 0; - cursor: pointer; - accent-color: var(--vscode-focusBorder, #007fd4); - transition: transform 0.15s ease; - - &:hover { - transform: scale(1.1); - } -} - -.modelInfo { - flex: 1 1 auto; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.modelName { - font-weight: 500; - color: var(--vscode-foreground); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.modelId { - font-family: var(--vscode-editor-font-family); - color: var(--vscode-descriptionForeground); - font-size: type.$font-size-2xs; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.modelBadges { - flex: 0 0 auto; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} - -.claudeTag, -.geminiTag, -.gptTag, -.thinkingTag, -.highTag, -.mediumTag, -.lowTag, -.extraLowTag, -.imageTag { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.2px; - line-height: 1.3; -} - -.claudeTag { - color: #f59e0b; - background: rgba(245, 158, 11, 0.12); - border: 1px solid rgba(245, 158, 11, 0.35); -} - -.geminiTag { - color: #60a5fa; - background: rgba(96, 165, 250, 0.12); - border: 1px solid rgba(96, 165, 250, 0.35); -} - -.gptTag { - color: #34d399; - background: rgba(52, 211, 153, 0.12); - border: 1px solid rgba(52, 211, 153, 0.35); -} - -.thinkingTag { - color: #c084fc; - background: rgba(192, 132, 252, 0.12); - border: 1px solid rgba(192, 132, 252, 0.35); -} - -.highTag { - color: #f87171; - background: rgba(248, 113, 113, 0.12); - border: 1px solid rgba(248, 113, 113, 0.35); -} - -.mediumTag { - color: #fbbf24; - background: rgba(251, 191, 36, 0.12); - border: 1px solid rgba(251, 191, 36, 0.35); -} - -.lowTag { - color: #2dd4bf; - background: rgba(45, 212, 191, 0.12); - border: 1px solid rgba(45, 212, 191, 0.35); -} - -.extraLowTag { - color: #38bdf8; - background: rgba(56, 189, 248, 0.12); - border: 1px solid rgba(56, 189, 248, 0.35); -} - -.imageTag { - color: #f472b6; - background: rgba(244, 114, 182, 0.12); - border: 1px solid rgba(244, 114, 182, 0.35); -} - -.resourceIdentity { - min-width: 0; - display: flex; - align-items: center; - gap: 12px; -} - -.resourceText { - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.resourceRowDisabled { - opacity: 0.6; -} - -.resourceActions { - display: flex; - align-items: center; - gap: 8px; -} - -.actionIconButton { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - - color: var(--vscode-textLink-foreground, #3b82f6); - background: color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 10%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 30%, transparent); - - &:hover:not(:disabled) { - color: #fff; - background: var(--vscode-textLink-foreground, #3b82f6); - border-color: var(--vscode-textLink-foreground, #3b82f6); - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } -} - -.refreshingSpin { - animation: spin 0.8s linear infinite; - opacity: 0.7; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.actionDeleteButton { - color: #ef4444; - background: color-mix(in srgb, #ef4444 10%, transparent); - border: 1px solid color-mix(in srgb, #ef4444 30%, transparent); - - &:hover:not(:disabled) { - color: #fff; - background: #ef4444; - border-color: #ef4444; - } -} - -.readyBadge, -.coolingBadge, -.invalidBadge, -.disabledBadge { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 3px 8px; - border-radius: 999px; - font-size: type.$font-size-2xs; - font-weight: 500; - letter-spacing: 0.1px; -} - -.badgeDot { - width: 6px; - height: 6px; - border-radius: 50%; -} - -.readyBadge { - color: var(--vscode-testing-iconPassed, #73c991); - background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 30%, transparent); - - .badgeDot { - background: var(--vscode-testing-iconPassed, #73c991); - box-shadow: 0 0 6px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 60%, transparent); - } -} - -.coolingBadge { - color: var(--vscode-editorWarning-foreground, #cca700); - background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 30%, transparent); - - .badgeDot { - background: var(--vscode-editorWarning-foreground, #cca700); - } -} - -.invalidBadge { - color: var(--vscode-errorForeground, #f48771); - background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 30%, transparent); - - .badgeDot { - background: var(--vscode-errorForeground, #f48771); - } -} - -.disabledBadge { - color: var(--vscode-descriptionForeground); - background: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent); - - .badgeDot { - background: var(--vscode-descriptionForeground); - } -} - .ready { color: var(--vscode-testing-iconPassed, #73c991) !important; } @@ -733,4 +153,4 @@ .error { color: var(--vscode-errorForeground, #f48771); font-size: type.$font-size-xs; -} \ No newline at end of file +} diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx index 09b3711..c94e69b 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx @@ -1,11 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { api, - getDisabledPluginAccountIds, - getDisabledPluginModelIds, - setPluginAccountEnabled, - setPluginModelEnabled, - setMultiplePluginModelsEnabled, pluginText, type PluginAddMethod, type PluginDescriptor, @@ -19,10 +14,6 @@ import { appStore } from "../../shared/store/appStore"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; import { FormField, TextInput } from "../../shared/ui/FormControls"; -import { Icon } from "../../shared/ui/Icon"; -import { refreshIcon, trashIcon } from "../../shared/ui/icons"; -import { Switch } from "../../shared/ui/Switch"; -import { TooltipTrigger } from "../../shared/ui/TooltipTrigger"; import styles from "./PluginResourcePanels.module.scss"; const PAGE_SIZE = 10; @@ -118,7 +109,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id); setBegun(next); setStatus("polling"); - await api.copyCursorText(next.userCode).catch(() => undefined); + if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined); await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl); } catch (cause) { setStatus("error"); @@ -126,23 +117,14 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { } }; + const userCode = begun?.userCode; return {pluginText(method.displayName, locale)} {method.description && {pluginText(method.description, locale)}} - {begun && status === "polling" &&
+ {userCode && status === "polling" &&
{t("设备验证码")} - - +
} @@ -160,7 +142,6 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: { export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); - const [activeProviderId, setActiveProviderId] = useState(null); const run = async (key: string, task: () => Promise) => { setBusy(key); @@ -175,40 +156,6 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { } }; - useEffect(() => { - let active = true; - const autoRefreshResources = async () => { - let didRefresh = false; - for (const res of plugin.resources) { - if (!res.canRefresh) continue; - for (const item of res.resources) { - if (!active) return; - // Auto-refresh if resource is marked invalid or missing metrics - if (item.state.status === "invalid" || item.metrics.length === 0) { - try { - await api.refreshPluginResource(plugin.id, res.type, item.id); - didRefresh = true; - } catch { - // ignore background refresh failure - } - } - } - } - if (active && didRefresh) { - await appStore.refreshPlugins(); - } - }; - void autoRefreshResources(); - return () => { active = false; }; - }, [plugin.id]); - - if (activeProviderId) { - const provider = plugin.providers.find((p) => p.id === activeProviderId); - if (provider) { - return setActiveProviderId(null)} />; - } - } - return
{plugin.providers.map((provider) => void run(`sync:${provider.id}`, async () => { await api.syncPluginModels(plugin.id, provider.id); })} - onViewModels={() => setActiveProviderId(provider.id)} />)} {plugin.resources.map((resource) => void run(`refresh:${item.id}`, async () => { await api.refreshPluginResource(plugin.id, resource.type, item.id); })} @@ -235,190 +181,33 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
; } -function ProviderRow({ provider, busy, syncing, onSync, onViewModels }: { +function ProviderRow({ provider, busy, syncing, onSync }: { provider: PluginProviderDescriptor; busy: boolean; syncing: boolean; onSync: () => void; - onViewModels: () => void; }) { const { locale } = useI18n(); - const [disabledIds, setDisabledIds] = useState>(() => getDisabledPluginModelIds()); - - useEffect(() => { - const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds()); - window.addEventListener("cursor_plugin_models_changed", handleUpdate); - return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); - }, []); - - const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length; - - return ( - -
-
- {pluginText(provider.displayName, locale)} - - {provider.providerType} - {" · "} - {provider.models.length > 0 - ? `${enabledCount}/${t("{count} 个模型", { count: provider.models.length })}` - : t("尚未同步模型")} - {" · "} - {provider.configured ? t("可调用") : t("未就绪")} - -
-
- {provider.models.length > 0 && ( - - )} - {provider.hasModels && ( - - )} -
-
-
- ); -} - -function ProviderModelsView({ provider, onBack }: { provider: PluginProviderDescriptor; onBack: () => void }) { - const { locale } = useI18n(); - const [search, setSearch] = useState(""); - const [disabledIds, setDisabledIds] = useState>(() => getDisabledPluginModelIds()); - - useEffect(() => { - const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds()); - window.addEventListener("cursor_plugin_models_changed", handleUpdate); - return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate); - }, []); - - const toggleModel = (modelId: string) => { - const isCurrentlyDisabled = disabledIds.has(modelId); - setPluginModelEnabled(modelId, isCurrentlyDisabled); - setDisabledIds(getDisabledPluginModelIds()); - }; - - const toggleAll = (enable: boolean) => { - const target = filteredModels.length > 0 ? filteredModels : provider.models; - setMultiplePluginModelsEnabled(target.map((m) => m.id), enable); - setDisabledIds(getDisabledPluginModelIds()); - }; - - const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length; - - const filteredModels = provider.models.filter((m) => { - if (!search.trim()) return true; - const q = search.toLowerCase(); - const shortId = m.id.split("/").pop() || m.id; - return m.displayName.toLowerCase().includes(q) || shortId.toLowerCase().includes(q); - }); - - return ( -
-
- - - -
- {pluginText(provider.displayName, locale)} - {t("模型列表")} - - {enabledCount}/{provider.models.length} - -
-
-
-
- - setSearch(e.target.value)} - className={styles.modelSearchInput} - /> - {search && ( - - )} -
-
- - -
-
-
- {filteredModels.map((m) => { - const shortId = m.id.split("/").pop() || m.id; - const nameLower = m.displayName.toLowerCase(); - const idLower = m.id.toLowerCase(); - const isEnabled = !disabledIds.has(m.id); - - const isClaude = nameLower.includes("claude") || idLower.includes("claude"); - const isGemini = nameLower.includes("gemini") || idLower.includes("gemini"); - const isGpt = nameLower.includes("gpt") || idLower.includes("gpt"); - const isThinking = nameLower.includes("thinking") || idLower.includes("thinking"); - const isHigh = nameLower.includes("(high)") || idLower.includes("-high"); - const isMedium = nameLower.includes("(medium)") || idLower.includes("-medium"); - const isLow = nameLower.includes("(low)") || idLower.includes("-low"); - const isExtraLow = nameLower.includes("(extra-low)") || idLower.includes("-extra-low"); - const isImage = nameLower.includes("image") || idLower.includes("image"); - - return ( -
toggleModel(m.id)} - > - toggleModel(m.id)} - className={styles.modelCheckbox} - onClick={(e) => e.stopPropagation()} - /> -
- {pluginText(m.displayName, locale) || shortId} - {shortId} -
-
- {isClaude && Claude} - {isGemini && Gemini} - {isGpt && GPT-OSS} - {isThinking && Thinking} - {isHigh && High} - {isMedium && Medium} - {isLow && Low} - {isExtraLow && Extra-Low} - {isImage && Image} -
-
- ); - })} - {filteredModels.length === 0 && ( -
- {t("暂无数据")} -
- )} -
+ return +
+ {pluginText(provider.displayName, locale)} + + {provider.providerType} + {" · "} + {provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")} + {" · "} + {provider.configured ? t("可调用") : t("未就绪")} +
- ); + {provider.hasModels && } +
; } -function ResourceList({ resource, busyKey, onRefresh, onDelete }: { +function ResourceList({ resource, busy, onRefresh, onDelete }: { resource: PluginResourceDescriptor; - busyKey: string | null; + busy: boolean; onRefresh: (item: PluginResourceView) => void; onDelete: (item: PluginResourceView) => void; }) { @@ -440,20 +229,14 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: { setQuery(event.target.value)} />
}
- {visible.map((item) => { - const isRefreshing = busyKey === `refresh:${item.id}`; - const isDeleting = busyKey === `delete:${item.id}`; - return onRefresh(item)} - onDelete={() => onDelete(item)} - />; - })} + {visible.map((item) => onRefresh(item)} + onDelete={() => onDelete(item)} + />)} {visible.length === 0 && {t("还没有资源,请先添加。")}}
{pageCount > 1 &&
@@ -465,116 +248,40 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: { ; } -function ResourceRow({ item, canRefresh, isRefreshing, isDeleting, disabled, onRefresh, onDelete }: { +function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: { item: PluginResourceView; canRefresh: boolean; - isRefreshing: boolean; - isDeleting: boolean; disabled: boolean; onRefresh: () => void; onDelete: () => void; }) { const { locale } = useI18n(); - const [disabledAccountIds, setDisabledAccountIds] = useState>(() => getDisabledPluginAccountIds()); - - useEffect(() => { - const handleUpdate = () => setDisabledAccountIds(getDisabledPluginAccountIds()); - window.addEventListener("cursor_plugin_accounts_changed", handleUpdate); - return () => window.removeEventListener("cursor_plugin_accounts_changed", handleUpdate); - }, []); - - const isEnabled = !disabledAccountIds.has(item.id); - - const toggleAccount = (checked: boolean) => { - setPluginAccountEnabled(item.id, checked); - setDisabledAccountIds(getDisabledPluginAccountIds()); - }; - - return -
- -
-
- {item.displayName} - {item.description && (() => { - const desc = pluginText(item.description, locale).trim(); - const isPro = desc.toLowerCase().includes("pro") || desc.toLowerCase().includes("ultra") || desc.toLowerCase().includes("premium") || desc.toLowerCase().includes("advanced"); - const label = isPro ? (desc.toLowerCase().includes("ultra") ? "ULTRA" : "PRO") : "FREE"; - return {isPro ? `🔥 ${label}` : label}; - })()} -
- {item.metrics.length > 0 && ( -
- {item.metrics.map((metric) => { - const isClaude = metric.id.toLowerCase().includes("claude"); - const isGemini = metric.id.toLowerCase().includes("gemini"); - const badgeStyle = isClaude - ? styles.claudeMetricBadge - : isGemini - ? styles.geminiMetricBadge - : styles.genericMetricBadge; - const shortLabel = isClaude ? "C" : isGemini ? "G" : pluginText(metric.label, locale); - return ( - - {shortLabel} - {Math.round(metric.value)}% - - ); - })} -
- )} -
+ return +
+ {item.displayName} + {item.description && {pluginText(item.description, locale)}} + {item.metrics.map((metric) => + {metric.unit === "percent" + ? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) }) + : `${pluginText(metric.label, locale)}: ${metric.value}`} + )}
-
- - {canRefresh && ( - - - - )} - - - +
+ + {canRefresh && } +
; } -function StateBadge({ isEnabled = true, state }: { isEnabled?: boolean; state: PluginResourceView["state"] }) { - if (!isEnabled) { - return {t("已停用")}; - } +function StateBadge({ state }: { state: PluginResourceView["state"] }) { if (state.status === "cooling") { - return {t("冷却中")}; + return {t("冷却中")}; } if (state.status === "invalid") { - return {t("已失效")}; + return {t("已失效")}; } - return {t("可用")}; + return {t("可用")}; } function errorText(cause: unknown) { diff --git a/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx index 7c14ea3..eff6f94 100644 --- a/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx @@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() { const nextVersion = await updateStore.check(); message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本")); } catch (cause) { - message(cause instanceof Error ? cause.message : String(cause)); + const error = cause instanceof Error ? cause.message : String(cause); + message(t("检查更新失败:{error}", { error })); } }; @@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() { try { await updateStore.install(); } catch (cause) { - message(cause instanceof Error ? cause.message : String(cause)); + const error = cause instanceof Error ? cause.message : String(cause); + message(t("安装更新失败:{error}", { error })); } }; diff --git a/apps/desktop/src/features/settings/CommitSettingsCard.module.scss b/apps/desktop/src/features/settings/CommitSettingsCard.module.scss new file mode 100644 index 0000000..472ae50 --- /dev/null +++ b/apps/desktop/src/features/settings/CommitSettingsCard.module.scss @@ -0,0 +1,76 @@ +@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; + + small { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } +} + +.details { + display: grid; + gap: 5px; +} + +.select { + 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 { + padding: 4px; + color: var(--vscode-textLink-foreground); + background: transparent; + border: 0; + + &:hover { color: var(--vscode-textLink-activeForeground); } + &:disabled { opacity: 0.5; cursor: not-allowed; } +} + +.promptEditor { + width: 100%; + flex: 1; + min-height: 320px; + box-sizing: border-box; + padding: 10px 12px; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border)); + border-radius: 6px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: type.$font-size-xs; + line-height: 1.6; + resize: none; + + &:focus { + 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 new file mode 100644 index 0000000..4a4bcc9 --- /dev/null +++ b/apps/desktop/src/features/settings/CommitSettingsCard.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +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 { 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) { + return cause instanceof Error ? cause.message : String(cause); +} + +export function CommitSettingsCard() { + 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(() => { + 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: ModelSelectOption[] = [{ value: "", label: t("直连"), group: "Cursor" }]; + const seen = new Set(); + for (const model of models) { + seen.add(model.model_hash); + options.push({ + value: model.model_hash, + 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, group: "Cursor" }); + } + return options; + }, [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, + prompt_locale: locale, + }); + }, + [view, locale], + ); + + 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); + } + } catch (cause) { + message(errorText(cause)); + } finally { + setSavingModel(false); + } + }, [modelDraft, view, persist, message]); + + const openPrompt = useCallback(() => { + if (!view) return; + setPromptDraft(view.prompt || view.default_prompt); + setPromptOpen(true); + }, [view]); + + const savePrompt = useCallback(async () => { + if (!view) return; + setSavingPrompt(true); + try { + const saved = await persist(view.model_id, promptDraft); + if (saved) setView(saved); + setPromptOpen(false); + message(t("提示词设置已保存")); + } catch (cause) { + message(errorText(cause)); + } finally { + setSavingPrompt(false); + } + }, [view, promptDraft, persist, message]); + + const resetPrompt = useCallback(() => { + if (!view) return; + 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("生成模型")} +
+ {editing ?
+ +
: {selectedModelLabel}} +
+
+
+ + setPromptOpen(false)} + onSubmit={() => void savePrompt()} + submitLabel={t("保存")} + closeLabel={t("取消")} + secondaryAction={ + + } + > +