Compare commits

..
Author SHA1 Message Date
leookun 8100652f87 chore: start provider and model interaction improvements 2026-08-25 00:07:44 +08:00
591 changed files with 75328 additions and 50188 deletions
-86
View File
@@ -1,86 +0,0 @@
---
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
└── <ModelSelect mode="single"> ── choose one ── close ── persist
Multiple owner value: string[]
└── <ModelSelect mode="multiple"> ── 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.
@@ -1,71 +0,0 @@
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;
});
}
@@ -1,41 +0,0 @@
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/);
});
-101
View File
@@ -1,101 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { 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 normalizeTauriUpdate(manifest, release, repository, version) {
if (manifest.version !== version) {
throw new Error(
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
);
}
if (release.tag_name !== `v${version}`) {
throw new Error(
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
);
}
if (!manifest.platforms || typeof manifest.platforms !== "object") {
throw new Error("updater manifest has no platforms");
}
const assetsBySourceUrl = new Map();
const publicAssetUrls = new Set();
for (const asset of release.assets ?? []) {
if (!asset?.id || !asset?.name) continue;
const publicUrl = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(asset.name)}`;
assetsBySourceUrl.set(
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
publicUrl,
);
if (asset.browser_download_url) {
assetsBySourceUrl.set(asset.browser_download_url, publicUrl);
}
publicAssetUrls.add(publicUrl);
}
for (const [platform, entry] of Object.entries(manifest.platforms)) {
if (!entry?.signature || !entry?.url) {
throw new Error(`updater platform ${platform} is missing its URL or signature`);
}
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
if (!publicAssetUrls.has(publicUrl)) {
throw new Error(`updater platform ${platform} references an unknown release asset`);
}
entry.url = publicUrl;
}
return manifest;
}
async function main() {
const options = readOptions(process.argv.slice(2));
const manifestPath = resolve(required(options, "manifest"));
const releasePath = resolve(required(options, "release"));
const repository = required(options, "repository");
const version = required(options, "version").replace(/^v/, "");
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const release = JSON.parse(await readFile(releasePath, "utf8"));
const normalized = normalizeTauriUpdate(
manifest,
release,
repository,
version,
);
await writeFile(manifestPath, `${JSON.stringify(normalized, 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;
});
}
-88
View File
@@ -1,88 +0,0 @@
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
+3 -60
View File
@@ -158,27 +158,14 @@ jobs:
mkdir -p legacy-update
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
- name: Package and sign legacy Windows updater asset
- name: Package 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
$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"
}
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
- name: Package legacy macOS updater asset
if: contains(matrix.platform, 'macos')
@@ -219,57 +206,13 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node .github/scripts/generate-legacy-update.mjs \
node scripts/release/generate-legacy-update.mjs \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--assets-dir legacy-update \
--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 }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
release_id=$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
)
test -n "${release_id}"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
test -n "${asset_id}"
gh api \
-H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
> tauri-update/latest.json
node .github/scripts/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
--version "${VERSION}"
- name: Upload normalized Tauri updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
- name: Upload legacy updater assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-16
View File
@@ -7,19 +7,3 @@
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
## Communication
- Lead with the conclusion. Keep responses direct and omit filler, repeated context, generic explanations, and narration of obvious steps.
- Match the level requested by the user: discuss architecture as architecture, behavior as behavior, and code details only when they materially support the answer.
- For architecture, refactoring, and module-boundary discussions, communicate primarily with annotated directory trees and ASCII architecture/data-flow diagrams instead of long prose.
- In directory trees, annotate every relevant directory and file with its single responsibility. When discussing code size, include line counts in the comments:
- use measured line counts for existing code;
- use clearly marked approximate targets such as `≈300 lines` for proposed code;
- state whether tests, generated code, and blank lines are excluded.
- Show the complete main execution path, including inputs, ownership boundaries, runtime loops, persistence, outputs, and extension points. Make the direction of data and control flow explicit.
- Clearly separate the current structure from the proposed structure. Explicitly list modules that move, merge, split, or are deleted.
- Use the user's vocabulary consistently. Do not introduce new domain terms when existing plain-language terms are sufficient; when an implementation name must be mentioned, distinguish it from the architecture concept.
- For stateful or concurrent behavior, show timing, ownership, state boundaries, and before/during/after behavior explicitly. Distinguish continuation within the same lifecycle from creation of a new lifecycle.
- Prefer compact trees, flow diagrams, state matrices, and mappings when they communicate the structure more clearly than paragraphs.
- Tie recommendations to the actual repository structure and code. Do not present a speculative target architecture as if it already exists.
Generated
+3 -280
View File
@@ -97,28 +97,6 @@ dependencies = [
"x11rb",
]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
@@ -208,17 +186,6 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -248,17 +215,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -970,7 +926,7 @@ dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -1172,13 +1128,10 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "1.0.1"
version = "0.1.0-beta.10"
dependencies = [
"axum",
"cursor-server",
"libc",
"mimalloc",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -1189,15 +1142,11 @@ 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]]
@@ -1234,7 +1183,6 @@ dependencies = [
"semble-core",
"serde",
"serde_json",
"serde_yaml",
"sha1 0.10.7",
"sha2",
"similar",
@@ -1253,7 +1201,6 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"x509-parser",
"zip",
]
[[package]]
@@ -1572,15 +1519,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -1935,7 +1873,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -1967,15 +1904,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1983,7 +1911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -1997,12 +1925,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2804,22 +2726,6 @@ dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-tungstenite"
version = "0.30.0"
@@ -3418,15 +3324,6 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.19"
@@ -3614,15 +3511,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -3751,23 +3639,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -4166,49 +4037,12 @@ dependencies = [
"libc",
]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4635,12 +4469,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -5214,11 +5042,9 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5229,7 +5055,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -5282,30 +5107,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2",
"dispatch2",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -5860,19 +5661,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -6399,12 +6187,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "1.0.109"
@@ -7128,16 +6910,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -7378,19 +7150,6 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.20",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -7728,12 +7487,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"
@@ -7765,12 +7518,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -7987,7 +7734,6 @@ dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
@@ -8046,8 +7792,6 @@ version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
@@ -9104,36 +8848,16 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.14.0",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
@@ -9186,7 +8910,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
+1 -1
View File
@@ -3,7 +3,7 @@ members = [
"server",
"apps/desktop/src-tauri",
"crates/semble-core",
"support/benchmarks/semble",
"benchmarks/semble",
]
resolver = "2"
+1 -1
View File
@@ -20,7 +20,7 @@ COPY apps/desktop/src-tauri/src/ apps/desktop/src-tauri/src/
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
COPY protocols/cursor/ protocols/cursor/
COPY scripts/cursor-proto/proto/ scripts/cursor-proto/proto/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
-8
View File
@@ -23,13 +23,6 @@ build-web:
build-server:
cargo build --release --package cursor-server --bin cursor-server
ifeq ($(OS),Windows_NT)
$(LOCAL_TAURI_SIGNING_KEY):
@powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '$(dir $@)' | Out-Null; & '$(CURDIR)/apps/desktop/node_modules/.bin/tauri.cmd' signer generate --ci --write-keys '$@'"
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
@node -e "const { spawnSync } = require('node:child_process'); const result = spawnSync(process.execPath, ['node_modules/@tauri-apps/cli/tauri.js', 'build', '--bundles', 'nsis'], { cwd: 'apps/desktop', stdio: 'inherit', env: { ...process.env, TAURI_SIGNING_PRIVATE_KEY: process.argv[1], TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' } }); process.exit(result.status ?? 1)" "$(LOCAL_TAURI_SIGNING_KEY)"
else
$(LOCAL_TAURI_SIGNING_KEY):
@install -d -m 700 "$(dir $@)"
@apps/desktop/node_modules/.bin/tauri signer generate --ci --write-keys "$@" >/dev/null
@@ -37,7 +30,6 @@ $(LOCAL_TAURI_SIGNING_KEY):
build-desktop: $(LOCAL_TAURI_SIGNING_KEY)
TAURI_SIGNING_PRIVATE_KEY="$(LOCAL_TAURI_SIGNING_KEY)" TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" npm --prefix apps/desktop run tauri:build
endif
build-docker:
docker build --tag cursor-byok:local .
-223
View File
@@ -1,223 +0,0 @@
<div align="center">
# cursor-byok
cursor-byok 是一个运行在本机的 Cursor 模型网关,帮助你在 Cursor 中使用自己配置的模型服务。
[English README](./README.md) · [使用指南](https://docs.leokun.cn) · [下载](https://github.com/leookun/cursor-byok/releases/latest) · [提交问题](https://github.com/leookun/cursor-byok/issues)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![将 cursor-byok 连接到多种模型 API](./images/en-brand-1.png)
![cursor-byok 控制面板](./images/en-home-1.png)
## 项目简介
cursor-byok 是一个开源的本地模型网关。它在你的设备上运行服务,接收 Cursor 发出的 Agent 请求,将请求转发到你配置的模型服务,并尽可能保留 Cursor Agent 的工具调用、Skills、MCP 和多轮对话能力。
你可以连接兼容 OpenAI 或 Anthropic 协议的服务,自定义服务地址、模型 ID、API Key 和请求参数,也可以使用 Cursor 平台默认选项之外的模型通道。
> [!IMPORTANT]
> cursor-byok 免费且开源,但你连接的模型服务商可能会按用量收费。本项目是独立项目,与 Cursor 或其开发者没有关联,也未获得其认可。
## 主要功能
- **自定义模型通道**:配置自己的 API 地址、凭据和模型 ID。
- **多种 API 协议**:支持 OpenAI Responses API、OpenAI Chat Completions API 和 Anthropic Messages API 兼容服务。
- **模型管理**:添加、复制、编辑、排序模型配置,并批量测试连接。
- **连接性能测试**:查看首字延迟、生成速度、总耗时和原始服务商响应。
- **Agent 工作流**:继续使用工具调用、Skills、MCP 和多轮对话。
- **会话指标**:查看 Token 用量、缓存命中率、对话轮次和估算价值。
- **TAB 补全服务**:在公益服务、官方直连和自定义服务之间选择连接方式。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载适合你操作系统的最新版本。
2. 启动 cursor-byok,打开 **Cursor 配置**,按提示初始化本地 CA(证书颁发机构)。
3. 在模型设置中添加模型,填写服务地址、API Key 和模型名称,然后保存并运行 **测试**。
4. 确认测试通过后,保持 cursor-byok 运行。
5. **首次升级 Cursor 或首次配置模型后,完全退出并重新启动 Cursor,然后新开一个对话**。在模型列表中选择已配置的模型,开始使用 Agent。
完整的安装步骤、配置说明和常见问题,请参阅[中文使用指南](https://docs.leokun.cn/zh/docs)。
> [!TIP]
> 首次升级 Cursor 或首次完成配置后,必须完全退出并重新启动 Cursor,再新开一个对话。配置前已经打开的对话不会加载新连接;使用自定义模型时,请在模型列表中手动选择该模型,不要选择 **Auto**。
## 模型配置
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
![模型设置页面](./images/en-model-1.png)
### 类型与协议选择
| 模型系列 | 模型类型 | 请求协议 |
| --- | --- | --- |
| Claude 系列 | **Anthropic** | Messages API |
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
| 其他模型 | **OpenAI** | **Chat Completions API** |
GPT 系列建议使用 **Responses API**。如果使用 Chat Completions,可能无法保留提示词缓存,导致速度变慢和费用增加。
### 常用字段
- **模型类型**:选择 OpenAI 或 Anthropic,决定上游接口格式。
- **请求协议**:OpenAI 类型需要继续选择 Responses API 或 Chat Completions API。
- **服务器地址**:可以填写服务商基础地址,让应用按协议追加标准端点,也可以填写完整请求 URL 并原样使用。
- **API Key**:填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
- **模型名称**:填写服务商接口接受的模型标识,也可以使用 **获取模型** 读取服务商返回的模型列表。
- **显示名称**:Cursor 模型列表中显示的名称,不会改变发送给上游的模型标识。
- **备注**:显示在 Cursor 的模型说明中。
还可以根据模型能力设置上下文窗口 Token、最大输出 Token、推理或思考强度、自定义 Headers,以及 OpenAI 或 Anthropic 的额外参数。自定义 Headers 和额外参数必须是 JSON 对象,只应填写服务商明确支持的字段。
保存配置后运行 **测试**,确认地址、协议、API Key、模型标识和流式响应都正常,再在 Cursor 中使用该模型。
## TAB 补全服务
Cursor 的 Tab 补全由独立的 TAB 服务处理,不经过模型通道。你可以在 **系统设置 → TAB 设置** 中选择以下模式:
- **使用公益服务(默认)**:使用项目作者部署的公共服务,无需额外配置。
- **直连**:直接连接当前 Cursor 账号对应的官方 TAB 服务,适合账号拥有官方额度的情况。
- **自定义**:自行部署 [`cursor-tab-server`](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后填写 TAB 服务地址。
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保新的连接方式生效。
## 与官方账号并存
新版设计支持 cursor-byok 与 Cursor 官方服务并存:
- 直接在 Cursor 中登录自己的账号。如果之前使用旧版生成的 fake 账户,请先退出该账户,再登录自己的账号。
- 账号拥有官方额度时,官方模型和本地模型可以随时切换混用。
- **Auto 只使用官方模型**,不会自动使用你配置的本地模型。账号没有官方额度时,请手动选择自己配置的模型。
- 插件、代码库索引等 Cursor 功能可以继续使用。
## 数据流转
```text
Cursor 客户端
│
│ Agent 请求与工具结果
▼
cursor-byok 本地服务
│
│ OpenAI / Anthropic 兼容请求
▼
你配置的模型 API
```
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商,请根据对应服务商的隐私政策和计费规则使用。
## 项目结构
```text
cursor-byok/
├── apps/
│ ├── desktop/
│ │ ├── src/
│ │ │ ├── features/ # 首页、模型、调用记录与设置
│ │ │ ├── shell/ # 窗口框架、页面布局与广告外壳
│ │ │ ├── shared/ # UI、虚拟列表、状态、API 与平台能力
│ │ │ ├── i18n/ # 本地化运行时与语言目录
│ │ │ └── styles/ # 全局主题与排版令牌
│ │ └── src-tauri/ # Tauri 桌面生命周期
├── server/
│ ├── src/
│ │ ├── cursor/ # Cursor 协议适配
│ │ ├── run/ # 通用 Agent Runtime 与内部 Port
│ │ ├── provider/ # 模型供应商适配
│ │ ├── model/ # 聚合领域模型
│ │ ├── store/ # SQLite Repository
│ │ ├── control/ # 管理面 API
│ │ ├── harness/ # Cursor 本地集成
│ │ └── search/ # Web 与 Semble 搜索接入
│ ├── prompt/
│ └── migrations/
├── crates/
│ └── semble-core/ # 本地代码索引与搜索核心库
├── protocols/
│ └── cursor/ # Cursor 协议定义的唯一来源
├── support/ # 仓库辅助设施
│ ├── cursor-protocol-extractor/ # Cursor 协议提取与 Go 代码生成工具
│ ├── cursor-capture/ # Cursor 协议抓取和调试工具
│ └── benchmarks/ # 代码搜索和索引基准测试
├── images/ # README 展示图片
├── Cargo.toml # Rust 工作区配置
└── Makefile # 常用开发、检查和构建命令
```
## 本地开发
### 环境要求
- Rust 工具链和 Cargo
- Node.js 与 npm
- Tauri 2 的系统构建依赖
- Docker(仅在构建 Docker 镜像时需要)
### 安装依赖
```bash
npm --prefix apps/desktop install
```
### 启动开发环境
启动桌面前端:
```bash
make dev-web
```
启动桌面应用:
```bash
make dev-desktop
```
### 检查与构建
运行完整检查:
```bash
make check
```
分别构建各部分:
```bash
make build-web # 构建桌面前端
make build-server # 构建 Rust 本地服务
make build-desktop # 构建 Tauri 桌面安装包
make build-docker # 构建 Docker 镜像
```
## 路线图
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
计划与进展请参阅[发布路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与反馈
- [中文使用指南](https://docs.leokun.cn/zh/docs)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 社区](https://t.me/cursor_byok)
- QQ 群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
提交问题时,请附上操作系统、cursor-byok 版本、模型类型、请求协议、已脱敏的服务地址、错误信息和复现步骤。请勿公开 API Key 或其他凭据。
## 参与贡献
欢迎提交 Issue 和 Pull Request。提交代码前请先阅读项目中的开发说明,并运行 `make check` 确认格式、测试和前端构建检查通过。
## 许可证
本项目采用 [MIT License](./LICENSE) 开源。
+5 -6
View File
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand-1.png)
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![cursor-byok dashboard](./images/en-home-1.png)
![cursor-byok dashboard](./images/en-home.png)
## About
@@ -45,16 +45,15 @@ You can connect OpenAI- and Anthropic-compatible services, customize endpoints,
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Test the model configuration. Once it passes, return to the dashboard and start the service.
5. After upgrading Cursor or configuring a model for the first time, quit Cursor completely and restart it, then start a new conversation and select the configured model.
4. Open Cursor, select the configured model, and start using Agent.
For complete installation steps, system configuration, and Frequently Asked Questions, see the [User Guide](https://docs.leokun.cn).
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model-1.png)
![cursor-byok model settings](./images/en-model.png)
## How It Works
-33
View File
@@ -1,33 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: var(--boot-bg, #141414);
}
</style>
<script>
// 在应用样式加载前按主题参数铺底色,避免切换 poster 时闪出错误颜色
(function () {
var light = new URLSearchParams(location.search).get("theme") === "default-light";
document.documentElement.style.setProperty("--boot-bg", light ? "#f5f5f5" : "#141414");
var meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = light ? "#f5f5f5" : "#141414";
document.head.appendChild(meta);
})();
</script>
<title>Cursor BYOK 产品演示</title>
</head>
<body>
<div id="root"></div>
<script src="/src/demo/index.tsx" type="module"></script>
</body>
</html>
+28 -23
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "1.0.1",
"version": "0.1.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "1.0.1",
"version": "0.1.0-beta.10",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
@@ -18,15 +18,15 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0"
},
"devDependencies": {
@@ -37,7 +37,6 @@
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1",
@@ -426,6 +425,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
@@ -1288,13 +1293,6 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1810,6 +1808,18 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2979,13 +2989,14 @@
"node": ">=0.10.0"
}
},
"node_modules/react-css-marquee": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-css-marquee/-/react-css-marquee-1.0.1.tgz",
"integrity": "sha512-jPHydXILF3oIP2gOvabfbEY3PUdbkHtDVA4mqYQiYvEdLWk9oqNJkUwDBxHK1RtltvQY3zkyuxTaGnw+G8x48w==",
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19"
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
@@ -3174,12 +3185,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "1.0.1",
"version": "0.1.0-beta.10",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
@@ -8,9 +8,8 @@
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
"i18n:scan": "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",
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
"serve": "vite preview",
@@ -29,15 +28,15 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-css-marquee": "^1.0.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0"
},
"devDependencies": {
@@ -48,7 +47,6 @@
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1",
+1 -1
View File
@@ -9,7 +9,7 @@ import { normalizePath, type Plugin } from "vite"
const traverse = traverseModule.default ?? traverseModule
const SOURCE_LOCALE = "zh-CN"
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"]
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
const AUTO_IMPORT_NAME = "__staticI18nT"
+1 -10
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "1.0.1"
version = "0.1.0-beta.10"
edition = "2021"
publish = false
@@ -14,9 +14,6 @@ tauri-build = { version = "2", features = [] }
[dependencies]
axum = "0.8"
cursor-server = { path = "../../../server" }
libc = "0.2"
mimalloc = "0.1"
rfd = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
@@ -26,14 +23,8 @@ 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"] }
+1 -5
View File
@@ -1,9 +1,5 @@
fn main() {
let manifest = tauri_build::AppManifest::new().commands(&[
"open_terminal_with_command",
"check_portable_update",
"install_portable_update",
]);
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri application")
}
@@ -16,10 +16,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"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",
@@ -1,11 +0,0 @@
# 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"]
@@ -1,11 +0,0 @@
# 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"]
+61 -152
View File
@@ -1,5 +1,5 @@
use std::{
process::{Command, ExitCode},
process::Command,
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
@@ -15,10 +15,11 @@ use axum::{
};
use tauri::{
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
WebviewWindow, WebviewWindowBuilder,
WebviewWindowBuilder,
};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(dev)]
use cursor_server::config::ConsoleSource;
@@ -26,17 +27,14 @@ use cursor_server::{App, Config, Result};
#[cfg(not(dev))]
use crate::frontend;
use crate::startup::{self, StartupDiagnostics};
use crate::tray;
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const AUTOSTART_ARG: &str = "--autostart";
struct DesktopRuntime {
shutdown: CancellationToken,
server: Mutex<Option<JoinHandle<Result<()>>>>,
exiting: AtomicBool,
server_addr: std::net::SocketAddr,
}
#[tauri::command]
@@ -54,30 +52,12 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> {
.spawn()?;
Ok(())
}
#[cfg(target_os = "linux")]
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
const TERMINALS: &[(&str, &[&str])] = &[
("x-terminal-emulator", &["-e"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["--execute"]),
("alacritty", &["-e"]),
("kitty", &[]),
];
let script = format!("{command}; exec bash");
for (terminal, separator) in TERMINALS {
let mut process = Command::new(terminal);
process.args(*separator);
process.arg("bash").arg("-c").arg(&script);
match process.spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
}
let _ = command;
Err(tauri::Error::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no supported terminal emulator found",
std::io::ErrorKind::Unsupported,
"terminal guidance is unsupported on this platform",
)))
}
}
@@ -116,10 +96,7 @@ fn desktop_api_router(app: AppHandle) -> Router {
.layer(Extension(app))
}
fn create_main_window(
app: &AppHandle,
address: std::net::SocketAddr,
) -> tauri::Result<WebviewWindow> {
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
let url = format!("http://{address}/__byok-api__/")
.parse()
.expect("local frontend URL");
@@ -131,93 +108,41 @@ fn create_main_window(
.background_color(Color(20, 20, 20, 255))
.decorations(cfg!(target_os = "macos"))
.shadow(true)
.resizable(true)
.visible(false);
.resizable(true);
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
builder.build()
}
/// 按需打开主窗口:webview 仅在需要界面时创建,关闭窗口即销毁释放内存。
pub(crate) fn open_main_window(app: &AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(());
}
let address = app.state::<DesktopRuntime>().server_addr;
let window = create_main_window(app, address)?;
window.show()?;
window.set_focus()?;
builder.build()?;
Ok(())
}
pub fn run() -> ExitCode {
let diagnostics = match StartupDiagnostics::initialize() {
Ok(diagnostics) => diagnostics,
Err(error) => {
startup::report_logging_failure(error.as_ref());
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,
architecture = std::env::consts::ARCH,
log_directory = %diagnostics.log_directory().display(),
"desktop starting"
);
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
pub fn run() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_server=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app = tauri::Builder::default()
.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) {
let _ = open_main_window(app);
}
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
tray::show_main_window(app);
}))
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| {
.setup(|app| {
app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]),
None,
))?;
let config = {
let mut config = Config::desktop()?;
// 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。
config.app_version = env!("CARGO_PKG_VERSION").into();
config
};
let config = Config::desktop()?;
#[cfg(dev)]
let config = {
let mut config = config;
@@ -235,12 +160,6 @@ pub fn run() -> ExitCode {
let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let desktop_settings =
tauri::async_runtime::block_on(server.store().desktop_settings())
.unwrap_or_default();
#[cfg(target_os = "macos")]
app.handle()
.set_dock_visibility(desktop_settings.show_dock_icon)?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone();
@@ -256,65 +175,55 @@ pub fn run() -> ExitCode {
shutdown,
server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false),
server_addr: address,
});
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; starting without the main window");
} else {
open_main_window(app.handle())?;
}
create_main_window(app.handle(), address)?;
tray::create(app)?;
crate::update::signal_ready_if_requested()?;
Ok(())
})
.build(tauri::generate_context!());
let app = match app {
Ok(app) => app,
Err(error) => {
diagnostics.report_fatal(&error);
return ExitCode::FAILURE;
}
};
.build(tauri::generate_context!())
.expect("failed to build Cursor BYOK desktop app");
app.run(|app, event| match event {
// code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出,
// 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。
RunEvent::ExitRequested { code, api, .. } => match code {
None => api.prevent_exit(),
Some(_) => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => {
tracing::error!(%error, "desktop server task failed")
}
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} if label == MAIN_WINDOW_LABEL => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.load(Ordering::Acquire) {
api.prevent_close();
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.hide();
}
}
},
}
RunEvent::ExitRequested { api, .. } => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
}
}
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
..
} => {
let _ = open_main_window(app);
}
} => tray::show_main_window(app),
_ => {}
});
ExitCode::SUCCESS
}
+1 -14
View File
@@ -1,19 +1,6 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod resource_limits;
mod startup;
mod tray;
mod update;
// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致
// 关闭窗口后 RSS 无法回落到静默启动水平。
#[global_allocator]
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
pub fn run() -> std::process::ExitCode {
if let Some(exit_code) = update::run_replacement_if_requested() {
return exit_code;
}
desktop::run()
}
pub use desktop::run;
+2 -2
View File
@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() -> std::process::ExitCode {
cursor_byok_desktop::run()
fn main() {
cursor_byok_desktop::run();
}
@@ -1,56 +0,0 @@
//! 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<OpenFileLimit> {
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,
})
}
-139
View File
@@ -1,139 +0,0 @@
//! Captures startup diagnostics before the database and desktop runtime initialize.
use std::{error::Error, path::PathBuf};
use rfd::{MessageButtons, MessageDialog, MessageLevel};
use tracing_appender::{
non_blocking::WorkerGuard,
rolling::{RollingFileAppender, Rotation},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const LOG_DIRECTORY_NAME: &str = "logs";
const LOG_FILE_PREFIX: &str = "cursor-byok";
const LOG_FILE_SUFFIX: &str = "log";
const RETAINED_LOG_FILES: usize = 15;
type BoxError = Box<dyn Error + Send + Sync>;
pub(crate) struct StartupDiagnostics {
log_directory: PathBuf,
_writer_guard: WorkerGuard,
}
impl StartupDiagnostics {
pub(crate) fn initialize() -> Result<Self, BoxError> {
let log_directory = cursor_server::config::managed_data_dir()?.join(LOG_DIRECTORY_NAME);
std::fs::create_dir_all(&log_directory)?;
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(LOG_FILE_PREFIX)
.filename_suffix(LOG_FILE_SUFFIX)
.max_log_files(RETAINED_LOG_FILES)
.build(&log_directory)?;
let (file_writer, writer_guard) = tracing_appender::non_blocking(file_appender);
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_byok_desktop=info,cursor_server=info".into());
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(file_writer),
)
.try_init()?;
Ok(Self {
log_directory,
_writer_guard: writer_guard,
})
}
pub(crate) fn log_directory(&self) -> &std::path::Path {
&self.log_directory
}
pub(crate) fn report_fatal(&self, error: &(dyn Error + 'static)) {
let details = error_chain(error);
tracing::error!(
error = %details,
log_directory = %self.log_directory.display(),
"desktop failed to start"
);
show_fatal_dialog(&details, Some(&self.log_directory));
}
}
pub(crate) fn report_logging_failure(error: &(dyn Error + 'static)) {
let details = error_chain(error);
eprintln!("Cursor BYOK failed to initialize logging: {details}");
show_fatal_dialog(&details, None);
}
fn show_fatal_dialog(details: &str, log_directory: Option<&std::path::Path>) {
let log_guidance = match log_directory {
Some(directory) => format!(
"日志目录 / Log directory:\n{}\n\n请将最新的日志文件发送给开发者。\nPlease send the latest log file to the developer.",
directory.display()
),
None => "日志系统也未能启动,因此没有生成日志文件。\nLogging also failed to initialize, so no log file was created."
.to_owned(),
};
let description = format!(
"Cursor BYOK 无法启动 / failed to start.\n\n错误 / Error:\n{details}\n\n{log_guidance}"
);
let _ = MessageDialog::new()
.set_level(MessageLevel::Error)
.set_title("Cursor BYOK 启动失败 / Startup Error")
.set_description(description)
.set_buttons(MessageButtons::Ok)
.show();
}
fn error_chain(error: &(dyn Error + 'static)) -> String {
let mut details = error.to_string();
let mut source = error.source();
while let Some(cause) = source {
details.push_str("\nCaused by: ");
details.push_str(&cause.to_string());
source = cause.source();
}
details
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt;
#[derive(Debug)]
struct OuterError(std::io::Error);
impl fmt::Display for OuterError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("database initialization failed")
}
}
impl Error for OuterError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.0)
}
}
#[test]
fn fatal_report_includes_the_complete_error_chain() {
let error = OuterError(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"database file is read-only",
));
assert_eq!(
error_chain(&error),
"database initialization failed\nCaused by: database file is read-only"
);
}
}
+12 -6
View File
@@ -1,13 +1,13 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
App,
App, AppHandle, Manager,
};
#[cfg(target_os = "windows")]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
use crate::desktop::open_main_window;
use crate::desktop::MAIN_WINDOW_LABEL;
const OPEN_MENU_ID: &str = "tray-open";
const QUIT_MENU_ID: &str = "tray-quit";
@@ -24,9 +24,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
.menu(&menu)
.show_menu_on_left_click(cfg!(target_os = "macos"))
.on_menu_event(|app, event| match event.id().as_ref() {
OPEN_MENU_ID => {
let _ = open_main_window(app);
}
OPEN_MENU_ID => show_main_window(app),
QUIT_MENU_ID => app.exit(0),
_ => {}
})
@@ -38,7 +36,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
..
} = event
{
let _ = open_main_window(tray.app_handle());
show_main_window(tray.app_handle());
}
#[cfg(not(target_os = "windows"))]
@@ -47,3 +45,11 @@ pub fn create(app: &mut App) -> tauri::Result<()> {
.build(app)?;
Ok(())
}
pub fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
-282
View File
@@ -1,282 +0,0 @@
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<ExitCode> {
#[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<Option<PortableUpdateInfo>, 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<Option<tauri_plugin_updater::Update>, 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<Vec<u8>, 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<PathBuf> {
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<u8> {
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());
}
}
@@ -1,296 +0,0 @@
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<Option<ReplacementRequest>, String> {
let args = std::env::args_os().collect::<Vec<_>>();
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::<u32>()
.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<PathBuf> {
let args = std::env::args_os().collect::<Vec<_>>();
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());
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "1.0.1",
"version": "0.1.0-beta.10",
"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: https:; font-src 'self' 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:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
+21 -49
View File
@@ -1,33 +1,30 @@
import { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { TooltipProvider } from "./shared/ui/Tooltip";
import { MessageProvider } from "./shared/ui/MessageProvider";
import { useMessage } from "./shared/ui/message";
import { AppFrame } from "./shell/AppFrame";
import { AppLayout } from "./shell/AppLayout";
import { CallsPage } from "./features/calls/CallsPage";
import { CallDetailsPage } from "./features/calls/CallDetailsPage";
import { CursorSettingsPage } from "./features/models/CursorSettingsPage";
import { HomePage } from "./features/home/HomePage";
import { PluginManagementPage } from "./features/plugins/PluginManagementPage";
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;
import { MessageProvider } from "./components/ui/MessageProvider";
import { useMessage } from "./components/ui/message";
import { AppFrame } from "./layouts/AppFrame";
import { AppLayout } from "./layouts/AppLayout";
import { CallsPage } from "./pages/CallsPage";
import { CallDetailsPage } from "./pages/CallDetailsPage";
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage";
import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore";
export function App() {
return (
<TooltipProvider>
<>
<HashRouter>
<Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} />
<Route element={<AppFrame />}>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="plugins" element={<PluginManagementPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
@@ -35,14 +32,13 @@ export function App() {
</Routes>
</HashRouter>
<AppMessages />
</TooltipProvider>
</>
);
}
function AppMessages() {
const { error } = useAppStore();
const previousError = useRef<string | null>(null);
const lastAutomaticUpdateCheckAt = useRef(0);
const showMessage = useMessage();
useEffect(() => {
@@ -51,36 +47,12 @@ function AppMessages() {
}, [error, showMessage]);
useEffect(() => {
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);
};
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.
});
}, [showMessage]);
return <MessageProvider />;
+313
View File
@@ -0,0 +1,313 @@
import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime";
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
export interface Provider {
provider_id: number;
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
has_api_key: boolean;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
created_at_ms: number;
updated_at_ms: number;
}
export interface ProviderInput {
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
}
export interface Model {
model_hash: string;
provider_id: number;
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number;
context_window_tokens: number | null;
max_output_tokens: number | null;
reasoning_enabled: boolean;
reasoning_effort: string | null;
supports_image_generation: boolean;
created_at_ms: number;
updated_at_ms: number;
}
export interface ModelInput {
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number;
context_window_tokens: number | null;
max_output_tokens: number | null;
reasoning_enabled: boolean;
reasoning_effort: string | null;
supports_image_generation: boolean;
}
export interface ModelConnectivityResult {
duration_ms: number;
first_text_ms: number | null;
output_tokens: number;
tokens_per_second: number;
tokens_estimated: boolean;
output: string;
}
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
export type IntegrationState = "disabled" | "enabled" | "degraded";
export interface CursorHarnessStatus {
platform: string;
ca: CaState;
configured_models: number;
enabled_models: number;
integration: IntegrationState;
proxy_url: string | null;
ca_install_command: string | null;
}
export interface PortSettings {
proxy_port: number;
service_port: number;
}
export interface StatisticsStorage {
bytes: number;
call_count: number;
trace_count: number;
}
export type ProxyMode = "system" | "custom";
export interface ProxySettings {
mode: ProxyMode;
address: string;
auth_enabled: boolean;
username: string;
has_password: boolean;
}
export interface ProxySettingsInput {
mode: ProxyMode;
address: string;
auth_enabled: boolean;
username: string;
password?: string;
}
export type TabMode = "public" | "direct" | "custom";
export interface TabSettings {
mode: TabMode;
address: string;
}
export interface OverviewMetrics {
llm_calls: number;
successful_calls: number;
failed_calls: number;
token_usage: number;
prompt_tokens: number;
input_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
output_tokens: number;
}
export type TokenUsageGranularity = "minute" | "hour" | "day";
export interface OverviewTokenUsageBucket {
bucket_start_ms: number;
input_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
output_tokens: number;
}
export interface Overview {
metrics: OverviewMetrics;
token_usage_granularity: TokenUsageGranularity;
token_usage_series: OverviewTokenUsageBucket[];
}
export type ProviderSelection =
| { kind: "existing"; provider_id: number }
| { kind: "new"; input: ProviderInput };
export interface LlmCall {
call_kind: "provider_llm" | "cursor_official";
route: "local_byok" | "cursor_official";
call_id: string;
run_id: string;
conversation_id: string;
provider_call_index: number;
model_hash: string | null;
provider_type: string;
provider_url: string;
request_type: string;
request_url: string;
model_id: string;
display_name: string;
reasoning_effort: string | null;
fast: boolean | null;
status: string;
finish_reason: string | null;
created_at_ms: number;
ttfb_ms: number | null;
ttft_ms: number | null;
duration_ms: number | null;
input_tokens: number | null;
output_tokens: number | null;
total_tokens: number | null;
cache_read_tokens: number | null;
cache_write_tokens: number | null;
reasoning_tokens: number | null;
message_count: number;
tool_count: number;
http_status: number | null;
error_kind: string | null;
error_message: string | null;
detailed: boolean;
}
export interface CallDetail {
call: LlmCall;
request: { headers: unknown; body: unknown; byte_count: number } | null;
response_chunks: Array<{ seq: number; received_offset_ms: number; data: string; byte_count: number }>;
cursor_trace: {
trace: {
request_id: string;
conversation_id: string | null;
route: "local_byok" | "cursor_official";
model_id: string | null;
status: string;
request_bytes: number;
response_bytes: number;
response_event_count: number;
http_status: number | null;
received_at_ms: number;
first_response_at_ms: number | null;
finished_at_ms: number | null;
error_message: string | null;
};
artifacts: Array<{
seq: number;
artifact_type: string;
source: string;
metadata: unknown;
created_at_ms: number;
byte_count: number;
encoding: "utf8" | "base64";
data: string;
}>;
} | null;
}
const packagedDesktop = "__TAURI_INTERNALS__" in window
|| window.location.protocol === "tauri:"
|| window.location.hostname === "tauri.localhost";
const API_ROOT = "/__byok-api__/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_ROOT}${path}`, {
...init,
headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers,
});
} catch (cause) {
throw new Error(t("无法连接本地管理服务"), { cause });
}
if (!response.ok) {
const body = await response.text();
let message = body;
try {
const parsed = JSON.parse(body) as { message?: unknown };
if (typeof parsed.message === "string") message = parsed.message;
} catch {
// Plain-text errors are already suitable for display.
}
throw new Error(message || `${response.status} ${response.statusText}`);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const api = {
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
const value = [...disabledAdIds].join(",");
return request<AdRuntime>("/ads", {
headers: {
"accept-language": locale,
...(value ? { "disable-ad-ids": value } : {}),
},
});
},
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
providers: () => request<Provider[]>("/providers"),
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
models: () => request<Model[]>("/models"),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
const params = new URLSearchParams();
if (filter) {
params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
}
const query = params.size ? `?${params}` : "";
return request<Overview>(`/overview${query}`);
},
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
openCursorCaInstallTerminal: async (command: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
const { invoke } = await import("@tauri-apps/api/core");
await invoke("open_terminal_with_command", { command });
},
copyCursorText: async (text: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中复制到系统剪贴板"));
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
await writeText(text);
},
setCursorEnabled: (enabled: boolean) => request<CursorHarnessStatus>("/harness/cursor/enabled", { method: "PUT", body: JSON.stringify({ enabled }) }),
calls: () => request<LlmCall[]>("/llm-calls?limit=200"),
call: (id: string) => request<CallDetail>(`/llm-calls/${encodeURIComponent(id)}`),
openCallDetails: async (id: string) => {
const url = new URL(window.location.href);
url.hash = `/calls/${encodeURIComponent(id)}`;
await request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url: url.toString() }) });
},
openExternalUrl: (url: string) => request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url }) }),
observability: () => request<{ detailed: boolean }>("/settings/observability"),
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
ports: () => request<PortSettings>("/settings/ports"),
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
};

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../styles/typography" as type;
.root {
section { min-width: 0; }
@@ -1,6 +1,6 @@
import type { CallDetail } from "../../shared/api";
import { JsonEditor } from "../../shared/ui/JsonEditor";
import { Tabs, type TabItem } from "../../shared/ui/Tabs";
import type { CallDetail } from "../api";
import { JsonEditor } from "./ui/JsonEditor";
import { Tabs, type TabItem } from "./ui/Tabs";
import styles from "./CallDetails.module.scss";
const show = (value: string | number | null) => value ?? "-";
@@ -32,7 +32,6 @@ export function CallDetails({ detail }: { detail: CallDetail }) {
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
[t("耗时"), timing(call.duration_ms)],
["TTFB", timing(call.ttfb_ms)],
["TTFR", timing(call.ttfr_ms)],
["TTFT", timing(call.ttft_ms)],
["Input Token", show(call.input_tokens)],
["Output Token", show(call.output_tokens)],
@@ -1,4 +1,4 @@
@use "../../styles/typography" as type;
@use "../styles/typography" as type;
.status {
padding: 3px 7px;
@@ -1,9 +1,9 @@
import type { LlmCall } from "../../shared/api";
import controls from "../../shared/ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "../../shared/ui/DataTable";
import { Icon } from "../../shared/ui/Icon";
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
import { eyeIcon } from "../../shared/ui/icons";
import type { LlmCall } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { eyeIcon } from "./ui/icons";
import styles from "./CallTable.module.scss";
const value = (input: string | number | null) => input ?? "-";
@@ -87,11 +87,6 @@ export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (
header: "TTFB",
render: (call) => milliseconds(call.ttfb_ms),
},
{
key: "ttfr",
header: "TTFR",
render: (call) => milliseconds(call.ttfr_ms),
},
{
key: "ttft",
header: "TTFT",
@@ -0,0 +1,15 @@
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.form { grid-template-columns: 1fr; }
.fullWidth { grid-column: auto; }
}
@@ -0,0 +1,30 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
import styles from "./ProviderEditor.module.scss";
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
value: ProviderInput;
headersText: string;
extraText: string;
editing: boolean;
onChange: (value: ProviderInput) => void;
onHeadersChange: (value: string) => void;
onExtraChange: (value: string) => void;
}) {
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
return <div className={styles.form}>
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
}
@@ -0,0 +1,14 @@
@use "../styles/typography" as type;
.badge {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.actions {
display: flex;
gap: 2px;
}
@@ -0,0 +1,31 @@
import type { Provider } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { editIcon, trashIcon } from "./ui/icons";
import styles from "./ProviderTable.module.scss";
const json = (value: unknown) => JSON.stringify(value);
export function ProviderTable({ providers, onEdit, onDelete }: {
providers: Provider[];
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
}) {
const columns: DataTableColumn<Provider>[] = [
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div> },
];
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
}
@@ -1,7 +1,6 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
import { Marquee } from "react-css-marquee";
import { Icon } from "../../shared/ui/Icon";
import { windowCloseIcon } from "../../shared/ui/icons";
import type { RefObject } from "react";
import { Icon } from "../ui/Icon";
import { windowCloseIcon } from "../ui/icons";
import type { AdSlot } from "./types";
import styles from "./Ads.module.scss";
@@ -15,49 +14,6 @@ type AdMenuProps = {
onDismiss: (ad: AdSlot) => void;
};
type OverflowTextProps = {
children: string;
className: string;
};
function OverflowText({ children, className }: OverflowTextProps) {
const container = useRef<HTMLDivElement>(null);
const text = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const viewport = container.current;
if (!viewport) return;
const update = () => {
const element = text.current;
if (!element) return;
setOverflowing(element.scrollWidth > viewport.clientWidth + 1);
};
update();
const observer = new ResizeObserver(update);
observer.observe(viewport);
if (text.current) observer.observe(text.current);
let disposed = false;
void document.fonts?.ready.then(() => {
if (!disposed) update();
});
return () => {
disposed = true;
observer.disconnect();
};
}, [children, overflowing]);
return <div ref={container} className={styles.menuOverflow}>
{overflowing
? <Marquee className={styles.menuMarquee} repeat loop={0} speed={12} gap={24}>
<span ref={text} className={className}>{children}</span>
</Marquee>
: <span ref={text} className={className}>{children}</span>}
</div>;
}
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
@@ -77,10 +33,10 @@ export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs
>
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
<div className={styles.menuCopy}>
<OverflowText className={styles.menuTitle}>{ad.target.title}</OverflowText>
<OverflowText className={styles.menuSubtitle}>{ad.target.description}</OverflowText>
</div>
<span className={styles.menuCopy}>
<span className={styles.menuTitle}>{ad.target.title}</span>
<span className={styles.menuSubtitle}>{ad.target.description}</span>
</span>
</button>
<button
type="button"
@@ -24,14 +24,19 @@
padding: 0 32px 0 8px;
color: inherit;
text-align: left;
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
border: 2px solid #be8d180d;
background: transparent;
border: 1px solid var(--vscode-sideBar-border);
border-radius: 6px;
&:hover,
&[aria-expanded="true"] {
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
border-color: #be8d180d;
background: var(--vscode-list-hoverBackground);
border: 1px solid var(--vscode-sideBar-border);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
}
@@ -42,7 +47,7 @@
width: 16px;
height: 16px;
padding: 0;
display: none;
display: flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
@@ -61,10 +66,6 @@
}
}
.menuSlotContainer:hover .menuDismiss {
display: flex;
}
.unreadDot {
position: absolute;
top: 5px;
@@ -84,9 +85,7 @@
}
.menuCopy {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1px;
@@ -94,43 +93,11 @@
.menuTitle,
.menuSubtitle {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menuOverflow {
flex: 0 0 auto;
width: 100%;
min-width: 0;
overflow: hidden;
}
.menuMarquee {
display: block;
flex: 0 0 auto;
width: 100%;
min-width: 0;
overflow: hidden;
}
.menuMarquee .menuTitle,
.menuMarquee .menuSubtitle {
flex: 0 0 auto;
width: max-content;
min-width: max-content;
max-width: none;
overflow: visible;
text-overflow: clip;
}
@media (prefers-reduced-motion: reduce) {
.menuMarquee [data-rcm-t] {
animation-play-state: running !important;
}
}
.menuTitle {
font-size: type.$font-size-xs;
font-weight: 500;
@@ -154,9 +121,9 @@
flex-direction: column;
overflow: hidden;
color: var(--vscode-foreground);
background: color-mix(in srgb, var(--vscode-editorWidget-background) 92%, var(--vscode-foreground) 8%);
background: var(--vscode-editorWidget-background);
border-radius: 15px;
border: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
border: none;
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
animation: ad-enter 180ms ease-out;
@@ -165,10 +132,6 @@
}
}
:global(:root[data-theme="default-light"]) .floatingAd {
background: var(--vscode-editorWidget-background);
}
.hero {
flex: 0 0 112px;
position: relative;
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../../shared/ui/Icon";
import { windowCloseIcon } from "../../shared/ui/icons";
import { VirtualList } from "../../shared/virtual/VirtualList";
import { Icon } from "../ui/Icon";
import { windowCloseIcon } from "../ui/icons";
import { VirtualList } from "../virtual/VirtualList";
import type { AdAction, AdSlot } from "./types";
import styles from "./Ads.module.scss";
@@ -1,4 +1,4 @@
@use "../../../styles/typography" as type;
@use "../../styles/typography" as type;
.root {
width: 100%;
@@ -1,8 +1,6 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../../i18n/runtime";
import { useI18n } from "../../../i18n/store";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = {
@@ -28,6 +26,12 @@ type CellExtra = CalendarCell & {
height: number;
};
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = {
key: string;
text: string;
@@ -50,6 +54,8 @@ const CALENDAR_CONFIG = {
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
@@ -66,10 +72,9 @@ function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
function buildCalendarLayout(data: ContributionDay[]) {
if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
@@ -83,22 +88,20 @@ function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const { show: showTooltip, hide: hideTooltip } = useTooltip();
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
const layout = useMemo(() => buildCalendarLayout(data), [data]);
layoutRef.current = layout;
useLayoutEffect(() => {
@@ -123,13 +126,10 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
},
};
showTooltip(anchor, undefined, <div className={styles.tooltipContent}>
<strong>{extra.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(extra.tokens) })}</span>
</div>);
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
};
const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) hideTooltip();
if (isCellExtra(event.target?.extra)) setTooltip(null);
};
chart.on("mouseover", handleMouseOver);
@@ -223,7 +223,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
cellRects.set(cell.date, rect);
chart.add(rect);
}
hideTooltip();
setTooltip(null);
};
const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame);
@@ -261,6 +261,12 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
</div>
</div>
<Tooltip anchor={tooltip?.anchor ?? null}>
<div className={styles.tooltipContent}>
<strong>{tooltip?.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
</div>
</Tooltip>
</section>
);
}
@@ -1,4 +1,4 @@
@use "../../../styles/typography" as type;
@use "../../styles/typography" as type;
.root {
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
@@ -1,7 +1,7 @@
import type { EChartsCoreOption } from "echarts/core";
import { useMemo, useState } from "react";
import type { TokenUsageGranularity } from "../../../shared/api";
import { formatCompactInteger } from "../../../shared/utils/numberFormat";
import type { TokenUsageGranularity } from "../../api";
import { formatCompactInteger } from "../../utils/numberFormat";
import { EChart } from "./EChart";
import styles from "./DailyTokenUsageChart.module.scss";
@@ -1,11 +1,11 @@
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
import { BarChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useRef, type MouseEventHandler } from "react";
import styles from "./EChart.module.scss";
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = {
option: EChartsCoreOption;
@@ -1,19 +1,18 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../../shared/api";
import type { LlmCall } from "../../api";
import { EChart } from "./EChart";
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
const points = calls.slice(0, 20).reverse();
const option: EChartsCoreOption = {
animationDuration: 280,
color: ["#d79a62", "#ad84cf", "#72a8d8"],
color: ["#d79a62", "#ad84cf"],
grid: { top: 22, right: 18, bottom: 30, left: 48 },
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
series: [
{ name: "TTFR", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttfr_ms ?? 0) },
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
],
@@ -1,5 +1,5 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../../shared/api";
import type { LlmCall } from "../../api";
import { EChart } from "./EChart";
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from "react";
import { useAppStore } from "../../store/appStore";
import controls from "../ui/Controls.module.scss";
import styles from "./CursorSettings.module.scss";
const CaReady = createContext(false);
const ModelsReady = createContext(false);
export function CursorCaProvider({ children }: { children: ReactNode }) {
const { cursorHarness } = useAppStore();
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
}
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
const ready = useContext(CaReady);
const { cursorHarness } = useAppStore();
if (ready) return children;
const unsupported = cursorHarness?.ca === "unsupported";
const installedLocally = cursorHarness?.ca === "untrusted";
return <div className={styles.gate}>
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
</div>;
}
export function CursorModelProvider({ children }: { children: ReactNode }) {
const { models } = useAppStore();
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
}
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
const ready = useContext(ModelsReady);
if (ready) return children;
return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
</div>;
}
@@ -0,0 +1,101 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { Checkbox } from "../ui/Checkbox";
import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import controls from "../ui/Controls.module.scss";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { claudeIcon, openAiIcon } from "../ui/icons";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
providerMode: string;
provider: ProviderInput;
model: ModelInput;
modelIds: string[];
headersText: string;
extraText: string;
customRequestUrl: boolean;
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerMode: "new",
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
modelIds: [],
headersText: defaultCustomHeadersText,
extraText: "{}",
customRequestUrl: false,
});
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft;
providers: Provider[];
editing: boolean;
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
}) {
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const canDiscover = draft.providerMode !== "new"
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
const selectProvider = (providerMode: string) => {
const endpointType = providerMode === "new"
? draft.provider.provider_type
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
};
const setEndpointType = (endpoint_type: ProviderType) => onChange({
...draft,
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
model: { ...draft.model, endpoint_type },
});
const setModelIds = (modelIds: string[]) => onChange({
...draft,
modelIds,
model: {
...draft.model,
model_id: modelIds[0] ?? "",
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
},
});
return <div className={styles.editor}>
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
{ value: "new", label: t("新建上游") },
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
]} onChange={selectProvider} /></FormField>}
<div className={styles.grid}>
{!editing && draft.providerMode === "new" && <>
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><SecretTextInput placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
</>}
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
}</FormField>
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
</>}
<div className={`${styles.switches} ${styles.fullWidth}`}>
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
</div>
</div>
</div>;
}
@@ -0,0 +1,35 @@
import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../ui/icons";
import styles from "./CursorSettings.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
const success = state.status === "success";
const summary = success
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
: t("错误:{error}", { error: state.error });
const detail = success
? t("速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", {
speed: formatSpeed(state.result.tokens_per_second),
firstText: state.result.first_text_ms ?? "--",
duration: state.result.duration_ms,
tokens: state.result.output_tokens,
estimated: state.result.tokens_estimated ? t("(估算)") : "",
output: state.result.output || "--",
})
: t("测试失败:{error}", { error: state.error });
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
<span className={styles.testResultText}>{summary}</span>
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
</div>;
}
function formatSpeed(value: number) {
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
}
@@ -0,0 +1,170 @@
@use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.gate {
min-height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--vscode-descriptionForeground);
text-align: center;
border: 1px dashed var(--vscode-sideBar-border);
border-radius: var(--oa-overlay-radius);
strong {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
max-width: 440px;
font-size: type.$font-size-xs;
}
button {
margin-top: 6px;
}
}
.groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.providerTitle {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 3px;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.models {
display: flex;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.badge {
padding: 2px 7px;
color: var(--vscode-badge-foreground);
background: var(--vscode-badge-background);
border-radius: 999px;
font-size: type.$font-size-2xs;
}
.rowActions {
display: flex;
gap: 2px;
}
.testResult {
min-width: 0;
max-width: 220px;
display: flex;
align-items: center;
gap: 5px;
font-size: type.$font-size-xs;
}
.testSuccess {
color: var(--vscode-testing-iconPassed, #73c991);
}
.testError {
color: var(--vscode-errorForeground, #f48771);
}
.testResultText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.testResultHint {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
color: currentColor;
cursor: help;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
.editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.editorTestResult {
margin-top: 14px;
}
.command {
margin: 0;
padding: 10px;
overflow: auto;
color: var(--vscode-textLink-foreground);
background: var(--vscode-textCodeBlock-background);
border-radius: 4px;
white-space: pre-wrap;
user-select: text;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
.switches {
display: flex;
flex-direction: column;
gap: 9px;
label {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
.fullWidth {
grid-column: auto;
}
}
@@ -0,0 +1,51 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -0,0 +1,53 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -15,8 +15,8 @@
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left:0;
font-size: type.$font-size-xxl;
padding-left: 10px;
font-size: type.$font-size-lg;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
@@ -26,8 +26,8 @@
top: 0;
margin: 0;
padding-left: 0;
font-size: type.$font-size-xxl;
padding-left: 10px;
font-size: type.$font-size-lg;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -1,5 +1,5 @@
import { useCallback, type ReactNode } from "react";
import { VirtualList } from "../../shared/virtual/VirtualListEngine";
import { VirtualList } from "../virtual/VirtualListEngine";
import styles from "./VirtualPage.module.scss";
export type VirtualPageSection = {
@@ -1,22 +1,18 @@
@use "../../../styles/typography" as type;
@use "../../styles/typography" as type;
.root {
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
position: relative;
width: 200px;
max-width: 100%;
width: 132px;
height: 82px;
overflow: hidden;
align-self: center;
flex: 0 0 auto;
}
.chart {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 140px;
pointer-events: none;
.canvas {
width: 100% !important;
height: 100% !important;
}
.label {
@@ -0,0 +1,75 @@
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
import { useMemo } from "react";
import { Doughnut } from "react-chartjs-2";
import styles from "./CacheHitRateChart.module.scss";
ChartJS.register(ArcElement, Tooltip);
type SegmentRadius = number | {
outerStart: number;
outerEnd: number;
innerStart: number;
innerEnd: number;
};
function chartColor(context: ScriptableContext<"doughnut">) {
const styles = getComputedStyle(context.chart.canvas);
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
return styles.getPropertyValue(variable).trim();
}
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
const radius = 5;
if (percentage <= 0) {
return dataIndex === 1
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
if (percentage >= 100) {
return dataIndex === 0
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
return dataIndex === 0
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
}
const options: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: { duration: 450 },
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
};
export function CacheHitRateChart({ rate }: { rate: number }) {
const finiteRate = Number.isFinite(rate) ? rate : 0;
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
const data = useMemo(() => ({
labels: [t("命中"), t("未命中")],
datasets: [{
data: [percentage, Math.max(0, 100 - percentage)],
backgroundColor: chartColor,
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
}],
}), [percentage]);
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
<Doughnut className={styles.canvas} data={data} options={options} />
<div className={styles.label}>{label}</div>
</div>;
}
@@ -1,4 +1,4 @@
@use "../../../styles/typography" as type;
@use "../../styles/typography" as type;
.scroller {
width: 100%;
@@ -1,8 +1,8 @@
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { useAppStore } from "../../../shared/store/appStore";
import { Icon } from "../../../shared/ui/Icon";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { informationOutlineIcon } from "../../../shared/ui/icons";
import { useState } from "react";
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
import { Icon } from "../ui/Icon";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import { informationOutlineIcon } from "../ui/icons";
import { CacheHitRateChart } from "./CacheHitRateChart";
import styles from "./HomeMetrics.module.scss";
@@ -16,6 +16,13 @@ export type HomeMetricsData = {
cacheWriteTokens: number;
};
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
} as const;
function formatMetricValue(value: number) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
@@ -46,21 +53,25 @@ function elementAnchor(element: HTMLElement): TooltipAnchor {
}
function InfoTooltip({ content }: { content: string }) {
const { show, hide } = useTooltip();
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
return <button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onMouseLeave={hide}
onFocus={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onBlur={hide}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>;
return <>
<button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
onMouseLeave={() => setAnchor(null)}
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
onBlur={() => setAnchor(null)}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
<Tooltip anchor={anchor}>
<div className={styles.tooltipText}>{content}</div>
</Tooltip>
</>;
}
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
const { pricing } = useAppStore();
export function HomeMetrics({ data }: { data: HomeMetricsData }) {
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
@@ -70,10 +81,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
);
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
const costs = {
input: priceTokens(inputTokens, pricing.input_per_million),
output: priceTokens(outputTokens, pricing.output_per_million),
cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million),
cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million),
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
};
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
const cacheCost = costs.cacheRead + costs.cacheWrite;
@@ -106,27 +117,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
t("缓存读写已计入提示词侧统计。"),
].join("\n");
const costTooltip = [
t("按配置的 Token 价格估算。"),
t("按 Claude Opus 4.7 价格估算。"),
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
"",
t("普通输入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(inputTokens),
price: pricing.input_per_million,
price: TOKEN_PRICE_PER_MILLION.input,
cost: formatUSD(costs.input),
}),
t("模型输出:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(outputTokens),
price: pricing.output_per_million,
price: TOKEN_PRICE_PER_MILLION.output,
cost: formatUSD(costs.output),
}),
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheReadTokens),
price: pricing.cache_read_per_million,
price: TOKEN_PRICE_PER_MILLION.cacheRead,
cost: formatUSD(costs.cacheRead),
}),
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
tokens: formatMetricValue(data.cacheWriteTokens),
price: pricing.cache_write_per_million,
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
cost: formatUSD(costs.cacheWrite),
}),
"",
@@ -137,7 +148,7 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
<section className={styles.root} aria-label={t("调用统计")}>
<article className={styles.metric}>
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
</article>
<article className={styles.metric}>
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
@@ -1,4 +1,4 @@
@use "../../../styles/typography" as type;
@use "../../styles/typography" as type;
.root,
.presets {
@@ -18,7 +18,7 @@
border-radius: 5px;
button {
height: 25px;
height: 30px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
@@ -76,22 +76,3 @@
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; }
}
}
@@ -1,32 +1,32 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
import controls from "../../../shared/ui/Controls.module.scss";
import { Icon } from "../../../shared/ui/Icon";
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
import { refreshIcon } from "../../../shared/ui/icons";
import { parseTimeInput } from "../../utils/parseTimeInput";
import controls from "../ui/Controls.module.scss";
import { Icon } from "../ui/Icon";
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { refreshIcon } from "../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, quick, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
quick: QuickPreset | null;
customOpen: boolean;
customStart: string;
customEnd: string;
modelOptions: ModelSelectOption[];
modelOptions: MultiSelectOption[];
providerOptions: MultiSelectOption[];
selectedModels: string[];
selectedProviders: string[];
busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onQuickSelect: (durationMs: number) => void;
onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
onSelectedModelsChange: (value: string[]) => void;
onSelectedProvidersChange: (value: string[]) => void;
onCustomApply: () => void;
onRefresh: () => void;
}) {
@@ -37,10 +37,6 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
{ 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 customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const popoverId = useId();
@@ -73,6 +69,7 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
const parsedStart = parseTimeInput(customStart);
const parsedEnd = parseTimeInput(customEnd);
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
return <div className={styles.root} aria-label={t("概览时间范围")}>
<div className={styles.presets}>
{presets.map((preset) => <button
@@ -101,7 +98,6 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
role="dialog"
aria-label={t("自定义概览筛选")}
style={position}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -110,17 +106,10 @@ export function OverviewTimeRangeFilter({ value, quick, customOpen, customStart,
}
}}
>
<div className={styles.quickPresets} aria-label={t("快捷时间范围")}>
{quickPresets.map((preset) => <button
key={preset.value}
type="button"
aria-pressed={quick === preset.value}
onClick={() => onQuickSelect(preset.value === "four-hours" ? 4 * 60 * 60_000 : 24 * 60 * 60_000)}
>{preset.label}</button>)}
</div>
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
<div className={styles.filterRow}><ModelSelect mode="multiple" label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
<div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
@@ -97,7 +97,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const hostRef = useRef<HTMLDivElement | null>(null)
const localViewportRef = useRef<HTMLDivElement | null>(null)
const localContentRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const thumbRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
@@ -108,7 +107,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
viewportRef: localViewportRef,
contentRef: localContentRef,
trackRef,
thumbRef,
contentHeight,
@@ -134,10 +132,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
)
const setContentRef = useCallback(
(node: HTMLDivElement | null) => {
localContentRef.current = node
assignRef(contentRef, node)
},
(node: HTMLDivElement | null) => assignRef(contentRef, node),
[contentRef]
)
@@ -33,7 +33,6 @@ export interface ScrollMetrics extends ScrollAreaState {
interface UseScrollableModelOptions {
viewportRef: RefObject<HTMLDivElement | null>
contentRef: RefObject<HTMLDivElement | null>
trackRef: RefObject<HTMLDivElement | null>
thumbRef: RefObject<HTMLDivElement | null>
contentHeight?: number
@@ -132,7 +131,6 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) {
const {
viewportRef,
contentRef,
trackRef,
thumbRef,
contentHeight,
@@ -321,30 +319,28 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
useEffect(() => {
const element = viewportRef.current
const content = contentRef.current
const track = trackRef.current
if (!element || !content || !track) return
if (!element || !track) return
const resizeObserver = new ResizeObserver((entries) => {
const viewportEntry = entries.find((entry) => entry.target === element)
if (viewportEntry) {
const entry = entries[0]
if (entry) {
onViewportResizeRef.current?.({
width: viewportEntry.contentRect.width,
height: viewportEntry.contentRect.height,
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
snapshot(false)
})
resizeObserver.observe(element)
resizeObserver.observe(content)
resizeObserver.observe(track)
snapshot(false)
return () => {
resizeObserver.disconnect()
}
}, [contentRef, snapshot, trackRef, viewportRef])
}, [snapshot, trackRef, viewportRef])
useEffect(() => {
snapshot(false)
@@ -1,32 +1,24 @@
import { useEffect, useState } from "react";
import {
currentAppVersion,
hasDockVisibilitySetting,
hasNativeAppLifecycle,
readAutostart,
readDesktopSettings,
writeAutostart,
writeDockIconVisibility,
writeSilentStart,
} from "../../shared/native/appLifecycle";
import { updateStore, useUpdateStore } from "../../shared/store/updateStore";
import { Button } from "../../shared/ui/Button";
import { Switch } from "../../shared/ui/Switch";
import { TitledCard } from "../../shared/ui/TitledCard";
import { useMessage } from "../../shared/ui/message";
} from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore";
import { Button } from "../ui/Button";
import { Switch } from "../ui/Switch";
import { TitledCard } from "../ui/TitledCard";
import { useMessage } from "../ui/message";
import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() {
const message = useMessage();
const native = hasNativeAppLifecycle();
const dockVisibilitySetting = hasDockVisibilitySetting();
const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native);
const [silentStart, setSilentStart] = useState(false);
const [dockIconVisible, setDockIconVisible] = useState(true);
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
useEffect(() => {
let disposed = false;
@@ -36,17 +28,6 @@ export function AppLifecycleSettingsCard() {
.then((enabled) => { if (!disposed) setAutostart(enabled); })
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); });
void readDesktopSettings()
.then((settings) => {
if (disposed) return;
setSilentStart(settings.silent_start);
setDockIconVisible(settings.show_dock_icon);
})
.catch(() => {})
.finally(() => {
if (disposed) return;
setLoadingDesktopSettings(false);
});
}
return () => { disposed = true; };
}, [message, native]);
@@ -64,39 +45,12 @@ export function AppLifecycleSettingsCard() {
}
};
const toggleSilentStart = async (enabled: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeSilentStart(enabled);
setSilentStart(enabled);
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const toggleDockIcon = async (visible: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeDockIconVisibility(visible);
setDockIconVisible(visible);
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const checkUpdate = async () => {
try {
const nextVersion = await updateStore.check();
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
} catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause);
message(t("检查更新失败:{error}", { error }));
message(cause instanceof Error ? cause.message : String(cause));
}
};
@@ -104,8 +58,7 @@ export function AppLifecycleSettingsCard() {
try {
await updateStore.install();
} catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause);
message(t("安装更新失败:{error}", { error }));
message(cause instanceof Error ? cause.message : String(cause));
}
};
@@ -122,30 +75,6 @@ export function AppLifecycleSettingsCard() {
onChange={(enabled) => void toggleAutostart(enabled)}
/>
</div>
{autostart && <div className={styles.row}>
<div>
<strong>{t("静默启动")}</strong>
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
</div>
<Switch
checked={silentStart}
disabled={!native || loadingDesktopSettings}
label={t("静默启动")}
onChange={(enabled) => void toggleSilentStart(enabled)}
/>
</div>}
{dockVisibilitySetting && <div className={styles.row}>
<div>
<strong>{t("在 Dock 栏显示")}</strong>
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
</div>
<Switch
checked={dockIconVisible}
disabled={loadingDesktopSettings}
label={t("在 Dock 栏显示")}
onChange={(visible) => void toggleDockIcon(visible)}
/>
</div>}
<div className={styles.row}>
<div>
<strong>{t("软件更新")}</strong>
@@ -20,7 +20,7 @@
}
.control {
width: min(320px, 50%);
width: min(420px, 62%);
}
.value {
@@ -1,9 +1,9 @@
import type { ProxySettings, ProxySettingsInput } from "../../shared/api";
import { Button } from "../../shared/ui/Button";
import { Checkbox } from "../../shared/ui/Checkbox";
import { TextInput } from "../../shared/ui/FormControls";
import { Select } from "../../shared/ui/Select";
import { TitledCard } from "../../shared/ui/TitledCard";
import type { ProxySettings, ProxySettingsInput } from "../../api";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { TextInput } from "../ui/FormControls";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./ProxySettingsCard.module.scss";
export function ProxySettingsCard({
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
onSave: () => void;
}) {
const custom = draft.mode === "custom";
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? t("默认") : t("自定义");
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
@@ -43,7 +43,7 @@ export function ProxySettingsCard({
{editing ? <>
<div className={styles.row}>
<strong>{t("代理方式")}</strong>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "default", label: t("默认") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
</div>
{custom && <div className={styles.customFields}>
<div className={styles.row}>
@@ -1,4 +1,4 @@
import type { ComponentProps } from "react";
import type { ButtonHTMLAttributes } from "react";
import controls from "./Controls.module.scss";
export type ButtonVariant = "primary" | "secondary";
@@ -10,7 +10,7 @@ export function Button({
className,
type = "button",
...props
}: ComponentProps<"button"> & {
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
+12
View File
@@ -0,0 +1,12 @@
import type { ElementType, ReactNode } from "react";
import styles from "./Card.module.scss";
type CardProps = {
as?: ElementType;
className?: string;
children: ReactNode;
};
export function Card({ as: Component = "div", className, children }: CardProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
}
@@ -8,7 +8,6 @@
color: var(--vscode-foreground);
font-size: type.$font-size-xs;
cursor: pointer;
flex-shrink: 0;
input {
position: absolute;
@@ -8,7 +8,6 @@
background: var(--vscode-button-background);
border: 0;
border-radius: 5px;
&:disabled { opacity: .5; cursor: not-allowed; }
}
.secondary {
@@ -35,8 +34,8 @@
}
.iconButton {
width: 25px;
height: 25px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
@@ -47,16 +47,6 @@
.input {
height: 34px;
padding: 0 10px;
&[type="number"] {
appearance: textfield;
}
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
.secret {
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
const [visible, setVisible] = useState(false);
return <div className={styles.secret}>
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
</button>
</div>;
@@ -34,14 +34,12 @@
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
header {
flex: 0 0 auto;
padding: 10px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
font-size: type.$font-size-base;
font-weight: 630;
}
footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 8px;
@@ -54,23 +52,15 @@
width: min(1080px, calc(100vw - 48px));
}
.compact {
width: min(560px, calc(100vw - 48px));
}
.fullHeight {
height: calc(100vh - var(--window-header-height) - 24px);
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.body {
flex: 1 1 auto;
flex: 0 1 auto;
min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyViewport {
height: auto;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyContent {

Some files were not shown because too many files have changed in this diff Show More