Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4234da4b4b | ||
|
|
cfc333dde4 |
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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,27 +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 }}
|
||||
@@ -258,7 +231,7 @@ jobs:
|
||||
-H "Accept: application/octet-stream" \
|
||||
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
> tauri-update/latest.json
|
||||
node .github/scripts/normalize-tauri-update.mjs \
|
||||
node scripts/release/normalize-tauri-update.mjs \
|
||||
--manifest tauri-update/latest.json \
|
||||
--release tauri-update/release.json \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -1172,13 +1128,10 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "1.0.1"
|
||||
version = "0.1.4"
|
||||
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]]
|
||||
@@ -1253,7 +1202,6 @@ dependencies = [
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"x509-parser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1572,15 +1520,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 +1874,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3418,15 +3356,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 +3543,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"
|
||||
@@ -4635,12 +4555,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"
|
||||
@@ -5282,30 +5196,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"
|
||||
@@ -6399,12 +6289,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"
|
||||
@@ -7378,19 +7262,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"
|
||||
@@ -7765,12 +7636,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 +7852,6 @@ dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
@@ -8046,8 +7910,6 @@ version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -9104,36 +8966,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 +9028,6 @@ dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = [
|
||||
"server",
|
||||
"apps/desktop/src-tauri",
|
||||
"crates/semble-core",
|
||||
"support/benchmarks/semble",
|
||||
"benchmarks/semble",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||
|
||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
||||
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
npm --prefix apps/docs run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-docs:
|
||||
npm --prefix apps/docs run dev
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
@@ -20,16 +24,12 @@ dev-desktop:
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-docs:
|
||||
npm --prefix apps/docs run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
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 +37,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 .
|
||||
|
||||
@@ -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)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 项目简介
|
||||
|
||||
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**。
|
||||
|
||||
## 模型配置
|
||||
|
||||
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
|
||||
|
||||

|
||||
|
||||
### 类型与协议选择
|
||||
|
||||
| 模型系列 | 模型类型 | 请求协议 |
|
||||
| --- | --- | --- |
|
||||
| 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) 开源。
|
||||
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
|
||||
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
@@ -10,20 +11,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--boot-bg, #141414);
|
||||
background: #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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "1.0.1",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "1.0.1",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
@@ -23,7 +23,6 @@
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-css-marquee": "^1.0.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
@@ -2979,15 +2978,6 @@
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "1.0.1",
|
||||
"version": "0.1.4",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "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",
|
||||
@@ -34,7 +34,6 @@
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-css-marquee": "^1.0.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"sortablejs": "^1.15.7",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "1.0.1"
|
||||
version = "0.1.4"
|
||||
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,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")
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"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"]
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
process::{Command, ExitCode},
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
@@ -19,6 +19,7 @@ use tauri::{
|
||||
};
|
||||
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,7 +27,6 @@ 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";
|
||||
@@ -36,7 +36,6 @@ struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
exiting: AtomicBool,
|
||||
server_addr: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -142,65 +141,22 @@ fn create_main_window(
|
||||
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()?;
|
||||
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"
|
||||
);
|
||||
|
||||
pub fn run() {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
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,
|
||||
])
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
let _ = open_main_window(app);
|
||||
tray::show_main_window(app);
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -212,12 +168,7 @@ pub fn run() -> ExitCode {
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec![AUTOSTART_ARG]),
|
||||
))?;
|
||||
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;
|
||||
@@ -256,65 +207,61 @@ pub fn run() -> ExitCode {
|
||||
shutdown,
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
server_addr: address,
|
||||
});
|
||||
let window = create_main_window(app.handle(), address)?;
|
||||
if desktop_settings.silent_start && started_by_autostart {
|
||||
tracing::info!("silent autostart enabled; starting without the main window");
|
||||
tracing::info!("silent autostart enabled; keeping the main window hidden");
|
||||
} else {
|
||||
open_main_window(app.handle())?;
|
||||
window.show()?;
|
||||
window.set_focus()?;
|
||||
}
|
||||
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,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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "1.0.1",
|
||||
"version": "0.1.4",
|
||||
"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"
|
||||
]
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
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 { 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 />} />
|
||||
@@ -27,7 +23,6 @@ export function App() {
|
||||
<Route index element={<HomePage />} />
|
||||
<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 +30,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 +45,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 />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AdRuntime } from "../shell/ads/types";
|
||||
import type { CommitPromptLocale, Locale } from "../i18n/runtime";
|
||||
import type { AdRuntime } from "./components/ads/types";
|
||||
import type { Locale } from "./i18n/runtime";
|
||||
|
||||
export type ModelType = "openai" | "anthropic";
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface Model {
|
||||
model_hash: string;
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
group_name: string | null;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
@@ -34,7 +33,6 @@ export interface Model {
|
||||
export interface ModelInput {
|
||||
sort_order: number;
|
||||
display_name: string;
|
||||
group_name: string | null;
|
||||
type: ModelType;
|
||||
base_url: string;
|
||||
use_full_url: boolean;
|
||||
@@ -88,7 +86,7 @@ export interface LegacyModelImportResult {
|
||||
|
||||
export interface ModelConnectivityResult {
|
||||
duration_ms: number;
|
||||
first_valid_response_ms: number | null;
|
||||
first_text_ms: number | null;
|
||||
output_tokens: number;
|
||||
tokens_per_second: number;
|
||||
tokens_estimated: boolean;
|
||||
@@ -103,7 +101,6 @@ export interface CursorHarnessStatus {
|
||||
configured_models: number;
|
||||
enabled_models: number;
|
||||
integration: IntegrationState;
|
||||
settings_applied: boolean;
|
||||
proxy_url: string | null;
|
||||
ca_install_command: string | null;
|
||||
}
|
||||
@@ -113,19 +110,13 @@ export interface PortSettings {
|
||||
service_port: number;
|
||||
}
|
||||
|
||||
export interface ExternalApiSettings {
|
||||
enabled: boolean;
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
export interface StatisticsStorage {
|
||||
bytes: number;
|
||||
call_count: number;
|
||||
trace_count: number;
|
||||
}
|
||||
|
||||
export type StatisticsStorageScope = "details" | "all";
|
||||
|
||||
export type ProxyMode = "default" | "custom";
|
||||
export type ProxyMode = "system" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
mode: ProxyMode;
|
||||
@@ -155,198 +146,6 @@ export interface DesktopSettings {
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export interface CommitSettings {
|
||||
model_id: string;
|
||||
prompt: string;
|
||||
prompt_locale: CommitPromptLocale;
|
||||
}
|
||||
|
||||
export interface CommitSettingsView extends CommitSettings {
|
||||
default_prompt: string;
|
||||
}
|
||||
|
||||
export interface TokenPricingSettings {
|
||||
input_per_million: number;
|
||||
output_per_million: number;
|
||||
cache_read_per_million: number;
|
||||
cache_write_per_million: number;
|
||||
}
|
||||
|
||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||
|
||||
export interface PluginRuntimeStatus {
|
||||
state: PluginRuntimeState;
|
||||
version: string;
|
||||
target: string | null;
|
||||
phase: PluginRuntimePhase | null;
|
||||
downloaded_bytes: number;
|
||||
total_bytes: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
|
||||
export type PluginLocalizedText = string | Record<string, string>;
|
||||
|
||||
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value[locale]) return value[locale];
|
||||
const language = locale.split("-")[0].toLowerCase();
|
||||
for (const [key, text] of Object.entries(value)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
|
||||
}
|
||||
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
export interface PluginResourceState {
|
||||
status: "ready" | "cooling" | "invalid";
|
||||
retryAtMs?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceMetric {
|
||||
id: string;
|
||||
label: PluginLocalizedText;
|
||||
unit: "percent" | "count";
|
||||
value: number;
|
||||
resetAtMs?: number | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceView {
|
||||
id: string;
|
||||
state: PluginResourceState;
|
||||
displayName: string;
|
||||
description: PluginLocalizedText | null;
|
||||
metrics: PluginResourceMetric[];
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0" | "oauth2.authorization-code";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
callback?: { port: number | null; path: string | null };
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
accept: string[];
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export interface PluginResourceAction {
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
target: "resource" | "card";
|
||||
destructive: boolean;
|
||||
}
|
||||
|
||||
export interface PluginResourceActionField {
|
||||
id: string;
|
||||
label: PluginLocalizedText;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PluginResourceActionCard {
|
||||
id: string;
|
||||
title: PluginLocalizedText;
|
||||
status: PluginLocalizedText | null;
|
||||
grantedAtMs: number | null;
|
||||
expiresAtMs: number | null;
|
||||
fields: PluginResourceActionField[];
|
||||
}
|
||||
|
||||
export interface PluginResourceActionResult {
|
||||
title: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
cards: PluginResourceActionCard[];
|
||||
}
|
||||
|
||||
export interface PluginResourceDescriptor {
|
||||
type: string;
|
||||
displayName: PluginLocalizedText;
|
||||
add: PluginAddMethod[];
|
||||
import: PluginImportDescriptor | null;
|
||||
canRefresh: boolean;
|
||||
canRemove: boolean;
|
||||
actions: PluginResourceAction[];
|
||||
resources: PluginResourceView[];
|
||||
}
|
||||
|
||||
export interface PluginModelDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
providerType: string;
|
||||
maxOutputTokens: number | null;
|
||||
images: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProviderDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
providerType: string;
|
||||
resourceType: string | null;
|
||||
hasModels: boolean;
|
||||
configured: boolean;
|
||||
models: PluginModelDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author: string | null;
|
||||
icon: string;
|
||||
providers: PluginProviderDescriptor[];
|
||||
resources: PluginResourceDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string | null;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export type PluginOAuthPoll =
|
||||
| { status: "pending"; pollIntervalMs: number }
|
||||
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
|
||||
| { status: "denied"; message: string | null }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface PluginImportFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PluginImportResult {
|
||||
added: number;
|
||||
updated: number;
|
||||
warnings: string[];
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models.filter((model) => model.enabled) : []));
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -396,7 +195,6 @@ export interface LlmCall {
|
||||
created_at_ms: number;
|
||||
ttfb_ms: number | null;
|
||||
ttft_ms: number | null;
|
||||
ttfr_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
@@ -478,14 +276,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
export const api = {
|
||||
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
|
||||
const value = [...disabledAdIds].join(",");
|
||||
return request<AdRuntime>("/promotions", {
|
||||
return request<AdRuntime>("/ads", {
|
||||
headers: {
|
||||
"accept-language": locale,
|
||||
...(value ? { "disable-ad-ids": value } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
dismissAd: (id: string, reason: string) => request<void>(`/promotions/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
|
||||
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
|
||||
models: () => request<Model[]>("/models"),
|
||||
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
|
||||
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
|
||||
@@ -494,35 +292,20 @@ export const api = {
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
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, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; bucketMs?: number }) => {
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
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.bucketMs) params.set("bucket_ms", String(filter.bucketMs));
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
||||
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
|
||||
pluginResourceAction: (pluginId: string, resourceType: string, resourceId: string, actionId: string, input: unknown = {}) => request<PluginResourceActionResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionId)}`, { method: "POST", body: JSON.stringify(input) }),
|
||||
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
|
||||
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
|
||||
setPluginModelEnabled: (pluginId: string, providerId: string, modelId: string, enabled: boolean) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }),
|
||||
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
|
||||
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
|
||||
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
|
||||
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
|
||||
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
@@ -546,18 +329,12 @@ export const api = {
|
||||
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) }),
|
||||
externalApiSettings: () => request<ExternalApiSettings>("/settings/external-api"),
|
||||
setExternalApiSettings: (settings: ExternalApiSettings) => request<ExternalApiSettings>("/settings/external-api", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
|
||||
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) }),
|
||||
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
|
||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
|
||||
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
pricingSettings: () => request<TokenPricingSettings>("/settings/pricing"),
|
||||
setPricingSettings: (settings: TokenPricingSettings) => request<TokenPricingSettings>("/settings/pricing", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 711 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 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",
|
||||
@@ -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,8 @@
|
||||
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 type { Locale } from "../../i18n/runtime";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import styles from "./ContributionCalendarChart.module.scss";
|
||||
|
||||
export type ContributionDay = {
|
||||
@@ -28,6 +28,12 @@ type CellExtra = CalendarCell & {
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
anchor: TooltipAnchor;
|
||||
};
|
||||
|
||||
type AxisLabel = {
|
||||
key: string;
|
||||
text: string;
|
||||
@@ -95,7 +101,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
|
||||
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]);
|
||||
@@ -123,13 +129,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 +226,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
|
||||
cellRects.set(cell.date, rect);
|
||||
chart.add(rect);
|
||||
}
|
||||
hideTooltip();
|
||||
setTooltip(null);
|
||||
};
|
||||
const scheduleDraw = () => {
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
@@ -261,6 +264,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,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[] }) {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useAppStore } from "../../shared/store/appStore";
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { useAppStore } from "../../store/appStore";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
const CaReady = createContext(false);
|
||||
@@ -24,9 +24,8 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models, plugins } = useAppStore();
|
||||
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
|
||||
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Card } from "../ui/Card";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelGrouping = "flat" | "provider" | "type";
|
||||
|
||||
export type CursorModelGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: IconifyIcon;
|
||||
models: Model[];
|
||||
};
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
testResults: Map<string, CursorModelTestState>;
|
||||
onTest: (model: Model) => void;
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
|
||||
const groups = new Map<string, CursorModelGroup>();
|
||||
for (const model of models) {
|
||||
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
|
||||
const group = groups.get(descriptor.key);
|
||||
if (group) {
|
||||
group.models.push(model);
|
||||
} else {
|
||||
groups.set(descriptor.key, { ...descriptor, models: [model] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ModelGrid({
|
||||
models,
|
||||
sortable: sortableEnabled,
|
||||
disabled,
|
||||
testingModelHashes,
|
||||
testResults,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onReorder,
|
||||
}: ModelGridProps) {
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const sortable = useRef<Sortable | null>(null);
|
||||
const currentModels = useRef(models);
|
||||
const reorder = useRef(onReorder);
|
||||
currentModels.current = models;
|
||||
reorder.current = onReorder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortableEnabled || !grid.current) return;
|
||||
sortable.current = Sortable.create(grid.current, {
|
||||
animation: 160,
|
||||
dataIdAttr: "data-model-hash",
|
||||
draggable: `.${styles.modelCard}`,
|
||||
handle: `.${styles.sortHandle}`,
|
||||
ghostClass: styles.sortGhost,
|
||||
chosenClass: styles.sortChosen,
|
||||
dragClass: styles.sortDragging,
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
fallbackTolerance: 3,
|
||||
onEnd: (event) => {
|
||||
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
|
||||
const newIndex = event.newDraggableIndex ?? event.newIndex;
|
||||
if (typeof oldIndex !== "number"
|
||||
|| typeof newIndex !== "number"
|
||||
|| oldIndex === newIndex) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
const reordered = currentModels.current.slice();
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
if (!moved || newIndex < 0 || newIndex > reordered.length) {
|
||||
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
|
||||
return;
|
||||
}
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
reorder.current(reordered.map((model) => model.model_hash));
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
sortable.current?.destroy();
|
||||
sortable.current = null;
|
||||
};
|
||||
}, [sortableEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
sortable.current?.option("disabled", disabled);
|
||||
sortable.current?.sort(models.map((model) => model.model_hash), false);
|
||||
}, [disabled, models]);
|
||||
|
||||
return <div ref={grid} className={styles.modelGrid}>
|
||||
{models.map((model) => {
|
||||
const result = testResults.get(model.model_hash);
|
||||
const testing = testingModelHashes.has(model.model_hash);
|
||||
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
|
||||
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
|
||||
<Icon icon={dragIcon} size="1.25em" />
|
||||
</button>}
|
||||
<div className={styles.modelCardContent}>
|
||||
<div className={styles.modelCardTop}>
|
||||
<div className={styles.modelCardName}>
|
||||
<span className={styles.modelCardNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelCardModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<span className={styles.modelTypeBadge}>
|
||||
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
|
||||
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.modelCardTest}>
|
||||
<CursorModelTestResult state={result} testing={testing} />
|
||||
</div>
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
|
||||
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function providerGroup(model: Model) {
|
||||
const label = providerDomain(model.base_url);
|
||||
return { key: label, label, icon: flatColorOrganizationIcon };
|
||||
}
|
||||
|
||||
function providerDomain(baseUrl: string) {
|
||||
const value = baseUrl.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function typeGroup(model: Model) {
|
||||
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
|
||||
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
|
||||
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useRef } from "react";
|
||||
import type { ModelInput, ModelType } from "../../shared/api";
|
||||
import { defaultCustomHeadersText } from "../../shared/utils/modelDefaults";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Checkbox } from "../../shared/ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormControls";
|
||||
import { JsonEditor } from "../../shared/ui/JsonEditor";
|
||||
import { Combobox, Select, type ComboboxHandle } from "../../shared/ui/Select";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import type { ModelInput, ModelType } from "../../api";
|
||||
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
|
||||
import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerId: string;
|
||||
model: ModelInput;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
@@ -21,11 +19,9 @@ export type CursorModelDraft = {
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerId: "builtin/openai",
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
group_name: null,
|
||||
type: "openai",
|
||||
base_url: "",
|
||||
use_full_url: false,
|
||||
@@ -56,9 +52,8 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => Promise<boolean>;
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const modelCombobox = useRef<ComboboxHandle>(null);
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const setType = (type: ModelType) => {
|
||||
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
|
||||
@@ -68,7 +63,6 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
providerId: `builtin/${type}`,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
@@ -91,9 +85,6 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
|
||||
.flatMap((preset) => preset.models.map((item) => item.model_id));
|
||||
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
|
||||
const discoverModels = async () => {
|
||||
if (await onDiscover()) modelCombobox.current?.openAll();
|
||||
};
|
||||
const applyPreset = (preset: ModelPreset) => {
|
||||
const endpoint = presetEndpoint(preset, draft.model.type);
|
||||
const first = preset.models[0];
|
||||
@@ -132,19 +123,13 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
const providerOptions = [
|
||||
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
];
|
||||
const setProvider = (providerId: string) => {
|
||||
if (providerId === "builtin/openai") setType("openai");
|
||||
if (providerId === "builtin/anthropic") setType("anthropic");
|
||||
};
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
|
||||
<div className={styles.grid}>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
@@ -156,7 +141,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
</div>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
|
||||
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}><Combobox ref={modelCombobox} value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={() => void discoverModels()}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
|
||||
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
|
||||
|
||||
@@ -37,19 +37,6 @@
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.compact {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ModelConnectivityResult } from "../../api";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import styles from "./CursorModelTestResult.module.scss";
|
||||
|
||||
export type CursorModelTestState =
|
||||
| { status: "success"; result: ModelConnectivityResult }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
|
||||
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.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function formatSpeed(value: number) {
|
||||
return Number.isFinite(value) ? value.toFixed(1) : "0.0";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ModelType } from "../../shared/api";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../shared/utils/modelPresets";
|
||||
import type { ModelType } from "../../api";
|
||||
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
|
||||
import styles from "./CursorPresetChips.module.scss";
|
||||
|
||||
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
|
||||
@@ -5,20 +5,6 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.takeoverActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.takeoverStatus {
|
||||
min-width: 39px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -84,90 +70,19 @@
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.groupCard {
|
||||
padding: 4px 12px 8px;
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.groupHeader {
|
||||
.modelGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSettings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { color: var(--vscode-foreground); }
|
||||
}
|
||||
.groupChevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
gap: 6px;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 2px;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-base;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelRowName {
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.modelRowNameText,
|
||||
.modelRowModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelRowNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
.modelRowModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
@@ -226,15 +141,9 @@
|
||||
}
|
||||
.modelCardActions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
// 空间不足时按钮收缩显示省略号,而不是换行。
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
.deleteButton:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
@@ -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,4 +1,4 @@
|
||||
@use "../../../styles/typography" as type;
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
@@ -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();
|
||||
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),
|
||||
}),
|
||||
"",
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { api, type LegacyModelImportPreview } from "../../shared/api";
|
||||
import { appStore } from "../../shared/store/appStore";
|
||||
import { ConfirmDialog } from "../../shared/ui/ConfirmDialog";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import { api, type LegacyModelImportPreview } from "../../api";
|
||||
import { appStore } from "../../store/appStore";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { useMessage } from "../ui/message";
|
||||
import styles from "./LegacyModelImport.module.scss";
|
||||
|
||||
type LegacyModelImportControl = {
|
||||
@@ -1,4 +1,4 @@
|
||||
@use "../../../styles/typography" as type;
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root,
|
||||
.presets {
|
||||
@@ -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,28 +1,25 @@
|
||||
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, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
quick: QuickPreset | null;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: ModelSelectOption[];
|
||||
modelOptions: MultiSelectOption[];
|
||||
selectedModels: 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;
|
||||
@@ -37,10 +34,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 +66,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 +95,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 +103,9 @@ 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.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
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() {
|
||||
@@ -95,8 +95,7 @@ export function AppLifecycleSettingsCard() {
|
||||
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 +103,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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(320px, 50%);
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
@@ -1,10 +1,10 @@
|
||||
import cursorIconUrl from "../../shared/assets/icons/cursor.svg";
|
||||
import type { TabMode, TabSettings } from "../../shared/api";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { TextInput } from "../../shared/ui/FormControls";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Select } from "../../shared/ui/Select";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
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({
|
||||
@@ -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;
|
||||
}) {
|
||||
@@ -8,7 +8,6 @@
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.secondary {
|
||||
@@ -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>;
|
||||