mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:52:55 +08:00
Merge main and host authorization-code OAuth in core
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: model-selection
|
||||
description: Implement and review desktop selection of configured models through the unified grouped ModelSelect component. Use when adding or changing model dropdowns, model filters, Commit model settings, single-model selection, multi-model selection, provider grouping, or configured-model option construction under apps/desktop.
|
||||
---
|
||||
|
||||
# Model Selection
|
||||
|
||||
Use one presentation component for every UI that selects from already configured models:
|
||||
|
||||
```text
|
||||
apps/desktop/src/
|
||||
├── shared/ui/
|
||||
│ ├── ModelSelect.tsx # Single/multiple selection behavior and grouped floating menu
|
||||
│ └── ModelSelect.module.scss # Trigger, provider headers, checkbox rows, and footer
|
||||
├── shared/utils/
|
||||
│ └── modelProvider.ts # Built-in model provider display name
|
||||
└── features/
|
||||
├── home/ # Builds overview multi-select options
|
||||
└── settings/ # Builds Commit single-select options and persists selection
|
||||
|
||||
server/src/
|
||||
├── cursor/services/commit_message.rs # Validates built-in/plugin IDs and creates Commit invocation
|
||||
├── provider/router.rs # Routes stable IDs to built-in or plugin providers
|
||||
└── plugin/registry.rs # Resolves plugin model IDs and executes plugin streams
|
||||
```
|
||||
|
||||
## Component boundary
|
||||
|
||||
- Use `ModelSelect` when choosing one or more existing configured models.
|
||||
- Do not implement another model dropdown, reuse generic `Select`, or add a compatibility wrapper for configured-model selection.
|
||||
- Keep `ModelSelect` presentation-only. Pages own model/plugin state, option construction, filtering, and persistence.
|
||||
- Keep editable model-ID entry in `Combobox`; creating or editing a model identifier is input, not configured-model selection.
|
||||
- Delete replaced model-selection components, exports, styles, helpers, and compatibility paths once references are gone.
|
||||
|
||||
## Option contract
|
||||
|
||||
Construct every `ModelSelectOption` with:
|
||||
|
||||
- `value`: stable persisted/request identifier. Built-in models use `model_hash`; plugin models use plugin model `id`.
|
||||
- `label`: user-facing model display name.
|
||||
- `group`: supplier display name.
|
||||
- `icon`/`iconSrc`: model/provider icon when available.
|
||||
|
||||
For built-in models, derive `group` with `modelProviderName(model)`. It uses the configured `group_name` first and the API hostname otherwise. For plugin models, use the localized provider display name.
|
||||
|
||||
Preserve source order within each supplier. The first occurrence of a supplier determines group order.
|
||||
|
||||
## Modes
|
||||
|
||||
```text
|
||||
Single owner value: string
|
||||
└── <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.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
function readOptions(args) {
|
||||
const options = new Map();
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const name = args[index];
|
||||
const value = args[index + 1];
|
||||
if (!name?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid argument near ${name ?? "end of command"}`);
|
||||
}
|
||||
options.set(name.slice(2), value);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function required(options, name) {
|
||||
const value = options.get(name)?.trim();
|
||||
if (!value) throw new Error(`--${name} is required`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function generatePortableUpdate({ version, repository, assetName, signature }) {
|
||||
const normalizedVersion = version.replace(/^v/, "");
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) {
|
||||
throw new Error(`invalid semantic version: ${normalizedVersion}`);
|
||||
}
|
||||
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
||||
throw new Error(`invalid GitHub repository: ${repository}`);
|
||||
}
|
||||
if (!assetName || basename(assetName) !== assetName) {
|
||||
throw new Error("asset name must be a file name");
|
||||
}
|
||||
if (!signature.trim()) throw new Error("signature is required");
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
notes: `Cursor BYOK v${normalizedVersion}`,
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
"windows-x86_64": {
|
||||
signature: signature.trim(),
|
||||
url: `https://github.com/${repository}/releases/download/v${normalizedVersion}/${encodeURIComponent(assetName)}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = readOptions(process.argv.slice(2));
|
||||
const version = required(options, "version");
|
||||
const repository = required(options, "repository");
|
||||
const asset = required(options, "asset");
|
||||
const signaturePath = resolve(required(options, "signature"));
|
||||
const output = resolve(required(options, "output"));
|
||||
const manifest = generatePortableUpdate({
|
||||
version,
|
||||
repository,
|
||||
assetName: basename(asset),
|
||||
signature: await readFile(signaturePath, "utf8"),
|
||||
});
|
||||
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { generatePortableUpdate } from "./generate-portable-update.mjs";
|
||||
|
||||
test("generates a signed Windows portable updater manifest", () => {
|
||||
const manifest = generatePortableUpdate({
|
||||
version: "v1.2.3-beta.1",
|
||||
repository: "owner/repository",
|
||||
assetName: "cursor-byok-1.2.3-beta.1-windows-amd64.zip",
|
||||
signature: "signed-payload\n",
|
||||
});
|
||||
|
||||
assert.equal(manifest.version, "1.2.3-beta.1");
|
||||
assert.deepEqual(Object.keys(manifest.platforms), ["windows-x86_64"]);
|
||||
assert.equal(manifest.platforms["windows-x86_64"].signature, "signed-payload");
|
||||
assert.equal(
|
||||
manifest.platforms["windows-x86_64"].url,
|
||||
"https://github.com/owner/repository/releases/download/v1.2.3-beta.1/cursor-byok-1.2.3-beta.1-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid inputs", () => {
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "latest",
|
||||
repository: "owner/repository",
|
||||
assetName: "update.zip",
|
||||
signature: "signature",
|
||||
}), /semantic version/);
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "1.2.3",
|
||||
repository: "owner/repository",
|
||||
assetName: "../update.zip",
|
||||
signature: "signature",
|
||||
}), /file name/);
|
||||
assert.throws(() => generatePortableUpdate({
|
||||
version: "1.2.3",
|
||||
repository: "owner/repository",
|
||||
assetName: "update.zip",
|
||||
signature: " ",
|
||||
}), /signature/);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Rust (Linux)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The workspace includes apps/desktop/src-tauri, so even `cargo check`
|
||||
# needs the same system libraries the release workflow installs.
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --all-targets
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: apps/desktop/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Check types and production build
|
||||
working-directory: apps/desktop
|
||||
run: npm run check
|
||||
|
||||
desktop-rust:
|
||||
name: Desktop Rust (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: ". -> target"
|
||||
|
||||
- name: Run platform Clippy
|
||||
run: cargo clippy --package cursor-byok-desktop --all-targets -- -D warnings
|
||||
@@ -158,14 +158,27 @@ jobs:
|
||||
mkdir -p legacy-update
|
||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
||||
|
||||
- name: Package legacy Windows updater asset
|
||||
- name: Package and sign legacy Windows updater asset
|
||||
if: matrix.platform == 'windows-x86_64'
|
||||
shell: pwsh
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
$asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
|
||||
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset
|
||||
Push-Location apps/desktop
|
||||
npm exec tauri signer sign -- "../../$asset"
|
||||
Pop-Location
|
||||
$entries = @(tar -tf $asset)
|
||||
if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') {
|
||||
throw "Windows updater archive must contain only cursor-byok-desktop.exe"
|
||||
}
|
||||
if (!(Test-Path "$asset.sig")) {
|
||||
throw "Windows updater archive signature was not generated"
|
||||
}
|
||||
|
||||
- name: Package legacy macOS updater asset
|
||||
if: contains(matrix.platform, 'macos')
|
||||
@@ -213,6 +226,20 @@ jobs:
|
||||
--output legacy-update/update.json \
|
||||
--notes "Cursor BYOK v${VERSION}"
|
||||
|
||||
- name: Generate signed Windows portable update manifest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
asset="cursor-byok-${VERSION}-windows-amd64.zip"
|
||||
test -f "legacy-update/${asset}"
|
||||
test -f "legacy-update/${asset}.sig"
|
||||
node .github/scripts/generate-portable-update.mjs \
|
||||
--version "${VERSION}" \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--asset "${asset}" \
|
||||
--signature "legacy-update/${asset}.sig" \
|
||||
--output legacy-update/portable-latest.json
|
||||
|
||||
- name: Normalize Tauri updater download URLs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
Generated
+5
-1
@@ -1172,10 +1172,11 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
"libc",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1187,12 +1188,15 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||
|
||||
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
npm --prefix apps/docs run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-docs:
|
||||
npm --prefix apps/docs run dev
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
@@ -24,9 +20,6 @@ dev-desktop:
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-docs:
|
||||
npm --prefix apps/docs run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ cursor-byok/
|
||||
│ │ │ ├── i18n/ # 本地化运行时与语言目录
|
||||
│ │ │ └── styles/ # 全局主题与排版令牌
|
||||
│ │ └── src-tauri/ # Tauri 桌面生命周期
|
||||
│ └── docs/ # Next.js 与 Fumadocs 中文/英文文档站
|
||||
├── server/
|
||||
│ ├── src/
|
||||
│ │ ├── cursor/ # Cursor 协议适配
|
||||
@@ -167,7 +166,6 @@ cursor-byok/
|
||||
|
||||
```bash
|
||||
npm --prefix apps/desktop install
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
@@ -184,14 +182,6 @@ make dev-web
|
||||
make dev-desktop
|
||||
```
|
||||
|
||||
启动文档站:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
文档站默认地址为 <http://localhost:3000>。
|
||||
|
||||
### 检查与构建
|
||||
|
||||
运行完整检查:
|
||||
@@ -205,13 +195,10 @@ make check
|
||||
```bash
|
||||
make build-web # 构建桌面前端
|
||||
make build-server # 构建 Rust 本地服务
|
||||
make build-docs # 构建文档站
|
||||
make build-desktop # 构建 Tauri 桌面安装包
|
||||
make build-docker # 构建 Docker 镜像
|
||||
```
|
||||
|
||||
文档内容位于 `apps/docs/content/docs` 和 `apps/docs/content/blog`。修改文档侧边栏时同步更新 `apps/docs/content/docs/meta.json`。
|
||||
|
||||
## 路线图
|
||||
|
||||
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cursor_byok_desktop"
|
||||
crate-type = ["rlib"]
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
libc = "0.2"
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -24,9 +25,14 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] }
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
fn main() {
|
||||
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||
let manifest = tauri_build::AppManifest::new().commands(&[
|
||||
"open_terminal_with_command",
|
||||
"check_portable_update",
|
||||
"install_portable_update",
|
||||
]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||
.expect("failed to build Tauri application")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"core:window:allow-close",
|
||||
"core:app:allow-set-dock-visibility",
|
||||
"allow-open-terminal-with-command",
|
||||
"allow-check-portable-update",
|
||||
"allow-install-portable-update",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"autostart:default",
|
||||
"process:allow-restart",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check-portable-update"
|
||||
description = "Enables the check_portable_update command without any pre-configured scope."
|
||||
commands.allow = ["check_portable_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check-portable-update"
|
||||
description = "Denies the check_portable_update command without any pre-configured scope."
|
||||
commands.deny = ["check_portable_update"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-install-portable-update"
|
||||
description = "Enables the install_portable_update command without any pre-configured scope."
|
||||
commands.allow = ["install_portable_update"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-install-portable-update"
|
||||
description = "Denies the install_portable_update command without any pre-configured scope."
|
||||
commands.deny = ["install_portable_update"]
|
||||
@@ -149,6 +149,23 @@ pub fn run() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let open_file_limit = match crate::resource_limits::raise_open_file_limit() {
|
||||
Ok(limit) => limit,
|
||||
Err(error) => {
|
||||
diagnostics.report_fatal(&error);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
requested = crate::resource_limits::REQUESTED_OPEN_FILE_LIMIT,
|
||||
previous = open_file_limit.previous,
|
||||
effective = open_file_limit.effective,
|
||||
hard = open_file_limit.hard,
|
||||
"open file limit configured"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
os = std::env::consts::OS,
|
||||
@@ -160,7 +177,11 @@ pub fn run() -> ExitCode {
|
||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_terminal_with_command,
|
||||
crate::update::check_portable_update,
|
||||
crate::update::install_portable_update,
|
||||
])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||
tray::show_main_window(app);
|
||||
@@ -228,6 +249,7 @@ pub fn run() -> ExitCode {
|
||||
window.set_focus()?;
|
||||
}
|
||||
tray::create(app)?;
|
||||
crate::update::signal_ready_if_requested()?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!());
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod resource_limits;
|
||||
mod startup;
|
||||
mod tray;
|
||||
mod update;
|
||||
|
||||
pub use desktop::run;
|
||||
pub fn run() -> std::process::ExitCode {
|
||||
if let Some(exit_code) = update::run_replacement_if_requested() {
|
||||
return exit_code;
|
||||
}
|
||||
desktop::run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Configures process resource limits before the desktop runtime starts.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::io;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) const REQUESTED_OPEN_FILE_LIMIT: u64 = 65_536;
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) struct OpenFileLimit {
|
||||
pub(crate) previous: u64,
|
||||
pub(crate) effective: u64,
|
||||
pub(crate) hard: u64,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn raise_open_file_limit() -> io::Result<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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
};
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
use std::{
|
||||
io::{Cursor, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod replacement;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const PORTABLE_UPDATE_ENDPOINT: &str =
|
||||
"https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json";
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PortableUpdateInfo {
|
||||
version: String,
|
||||
}
|
||||
|
||||
pub fn run_replacement_if_requested() -> Option<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, ExitCode},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const APPLY_ARG: &str = "--apply-portable-update";
|
||||
const TARGET_ARG: &str = "--update-target";
|
||||
const PID_ARG: &str = "--update-wait-pid";
|
||||
const HANDSHAKE_ARG: &str = "--update-handshake";
|
||||
pub(super) const READY_ARG: &str = "--portable-update-ready";
|
||||
const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(super) struct ReplacementRequest {
|
||||
target: PathBuf,
|
||||
pid: u32,
|
||||
handshake: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn request_from_args() -> Result<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": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: https:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
|
||||
@@ -13,6 +13,8 @@ import { SettingsPage } from "./features/settings/SettingsPage";
|
||||
import { useAppStore } from "./shared/store/appStore";
|
||||
import { updateStore } from "./shared/store/updateStore";
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
@@ -39,6 +41,7 @@ export function App() {
|
||||
function AppMessages() {
|
||||
const { error } = useAppStore();
|
||||
const previousError = useRef<string | null>(null);
|
||||
const lastAutomaticUpdateCheckAt = useRef(0);
|
||||
const showMessage = useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,12 +50,36 @@ function AppMessages() {
|
||||
}, [error, showMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateStore.check().then((version) => {
|
||||
if (!version) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||
}).catch(() => {
|
||||
// Startup checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
let disposed = false;
|
||||
const checkAutomatically = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastAutomaticUpdateCheckAt.current < AUTO_UPDATE_CHECK_INTERVAL_MS) return;
|
||||
lastAutomaticUpdateCheckAt.current = now;
|
||||
const previousVersion = updateStore.getSnapshot().availableVersion;
|
||||
void updateStore.check().then((version) => {
|
||||
if (disposed || !version || version === previousVersion) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
|
||||
}).catch(() => {
|
||||
if (!disposed) lastAutomaticUpdateCheckAt.current = 0;
|
||||
// Automatic checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
};
|
||||
const checkWhenVisible = () => {
|
||||
if (document.visibilityState === "visible") checkAutomatically();
|
||||
};
|
||||
|
||||
checkAutomatically();
|
||||
window.addEventListener("focus", checkAutomatically);
|
||||
window.addEventListener("online", checkAutomatically);
|
||||
document.addEventListener("visibilitychange", checkWhenVisible);
|
||||
const timer = window.setInterval(checkAutomatically, AUTO_UPDATE_CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.removeEventListener("focus", checkAutomatically);
|
||||
window.removeEventListener("online", checkAutomatically);
|
||||
document.removeEventListener("visibilitychange", checkWhenVisible);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [showMessage]);
|
||||
|
||||
return <MessageProvider />;
|
||||
|
||||
@@ -73,12 +73,13 @@ const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
|
||||
};
|
||||
});
|
||||
|
||||
const harnessStatus: CursorHarnessStatus = {
|
||||
let harnessStatus: CursorHarnessStatus = {
|
||||
platform: "macos",
|
||||
ca: "ready",
|
||||
configured_models: models.length,
|
||||
enabled_models: models.length,
|
||||
integration: "enabled",
|
||||
settings_applied: true,
|
||||
proxy_url: "http://127.0.0.1:54321",
|
||||
ca_install_command: null,
|
||||
};
|
||||
@@ -86,7 +87,7 @@ const harnessStatus: CursorHarnessStatus = {
|
||||
let detailed = true;
|
||||
let portSettings = { proxy_port: 0, service_port: 0 };
|
||||
let proxySettings: ProxySettings = {
|
||||
mode: "system",
|
||||
mode: "default",
|
||||
address: "",
|
||||
auth_enabled: false,
|
||||
username: "",
|
||||
@@ -126,7 +127,17 @@ export function installDemoApi() {
|
||||
if (path === "/llm-calls") return json(calls);
|
||||
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
|
||||
if (path === "/harness/cursor/status") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/enabled") {
|
||||
const enabled = Boolean((body as { enabled?: unknown } | null)?.enabled);
|
||||
harnessStatus = {
|
||||
...harnessStatus,
|
||||
integration: enabled ? "enabled" : "disabled",
|
||||
settings_applied: enabled,
|
||||
proxy_url: enabled ? "http://127.0.0.1:54321" : null,
|
||||
};
|
||||
return json(harnessStatus);
|
||||
}
|
||||
if (path === "/settings/observability" && method === "GET") return json({ detailed });
|
||||
if (path === "/settings/observability") {
|
||||
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
import { api, pluginText, type Overview } from "../../shared/api";
|
||||
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "./metrics/HomeMetrics";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import type { VirtualPageSection } from "../../shell/layout/VirtualPage";
|
||||
import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/OverviewTimeRangeFilter";
|
||||
import { OverviewTimeRangeFilter, type OverviewRangePreset, type QuickPreset } from "./overview/OverviewTimeRangeFilter";
|
||||
import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
|
||||
import { modelProviderName } from "../../shared/utils/modelProvider";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
@@ -25,13 +27,18 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
return { startMs: start.getTime(), endMs };
|
||||
}
|
||||
const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000;
|
||||
const duration = preset === "ten-minutes" ? 10 * 60_000
|
||||
: preset === "hour" ? 60 * 60_000
|
||||
: 7 * 24 * 60 * 60_000;
|
||||
return { startMs: now.getTime() - duration, endMs };
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models, plugins } = useAppStore();
|
||||
const { locale } = useI18n();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [quick, setQuick] = useState<QuickPreset | null>(null);
|
||||
const [fourHourBucket, setFourHourBucket] = useState<number | undefined>(undefined);
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
@@ -50,13 +57,14 @@ export function HomePage() {
|
||||
void api.overview({
|
||||
...selectedRange,
|
||||
modelHashes: appliedModels,
|
||||
bucketMs: fourHourBucket,
|
||||
}).then((next) => {
|
||||
if (active) setRangeOverview(next);
|
||||
}).finally(() => {
|
||||
if (active) setRangeBusy(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels]);
|
||||
}, [preset, customRange, overview, refreshVersion, appliedModels, fourHourBucket]);
|
||||
|
||||
const filteredOverview = rangeOverview ?? overview;
|
||||
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
|
||||
@@ -93,9 +101,26 @@ export function HomePage() {
|
||||
if (startMs === null || endMs === null || startMs >= endMs) return;
|
||||
setCustomRange({ startMs, endMs });
|
||||
setAppliedModels(selectedModels);
|
||||
setQuick(null);
|
||||
setFourHourBucket(undefined);
|
||||
setPreset("custom");
|
||||
setCustomOpen(false);
|
||||
};
|
||||
const selectQuick = (durationMs: number, bucketMs?: number) => {
|
||||
const endMs = Date.now();
|
||||
setCustomRange({ startMs: endMs - durationMs, endMs });
|
||||
setAppliedModels(selectedModels);
|
||||
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
|
||||
setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined);
|
||||
setPreset("custom");
|
||||
setCustomOpen(false);
|
||||
};
|
||||
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
|
||||
setPreset(value);
|
||||
setQuick(null);
|
||||
setFourHourBucket(undefined);
|
||||
setCustomOpen(false);
|
||||
};
|
||||
const refresh = async () => {
|
||||
await appStore.refresh();
|
||||
setRefreshVersion((version) => version + 1);
|
||||
@@ -105,13 +130,18 @@ export function HomePage() {
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
group: modelProviderName(model),
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
...plugins.flatMap((plugin) => plugin.providers.flatMap((provider) =>
|
||||
provider.configured ? provider.models.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
group: pluginText(provider.displayName, locale) || model.pluginName,
|
||||
iconSrc: model.icon || undefined,
|
||||
icon: model.icon ? undefined : flatColorOrganizationIcon,
|
||||
})) : [],
|
||||
)),
|
||||
];
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
@@ -138,13 +168,16 @@ export function HomePage() {
|
||||
return <>
|
||||
<PageActions><OverviewTimeRangeFilter
|
||||
value={preset}
|
||||
quick={quick}
|
||||
fourHourBucket={fourHourBucket}
|
||||
customOpen={customOpen}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
modelOptions={modelOptions}
|
||||
selectedModels={selectedModels}
|
||||
busy={busy || rangeBusy}
|
||||
onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
|
||||
onSelect={selectPreset}
|
||||
onQuickSelect={selectQuick}
|
||||
onCustomOpenChange={openCustom}
|
||||
onCustomStartChange={setCustomStart}
|
||||
onCustomEndChange={setCustomEnd}
|
||||
|
||||
@@ -76,3 +76,50 @@
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quickPresets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
button {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-button-secondaryBackground, var(--vscode-sideBar-background));
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
.granularityMenu {
|
||||
position: fixed;
|
||||
z-index: 15000;
|
||||
min-width: 96px;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
|
||||
&[aria-pressed="true"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,26 @@ import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../../shared/utils/parseTimeInput";
|
||||
import controls from "../../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../../shared/ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../../../shared/ui/MultiSelect";
|
||||
import { ModelSelect, type ModelSelectOption } from "../../../shared/ui/ModelSelect";
|
||||
import { TooltipTrigger } from "../../../shared/ui/TooltipTrigger";
|
||||
import { refreshIcon } from "../../../shared/ui/icons";
|
||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
|
||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||
export type QuickPreset = "four-hours" | "twenty-four-hours";
|
||||
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
||||
export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
quick: QuickPreset | null;
|
||||
fourHourBucket: number | undefined;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
modelOptions: ModelSelectOption[];
|
||||
selectedModels: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
onQuickSelect: (durationMs: number, bucketMs?: number) => void;
|
||||
onCustomOpenChange: (open: boolean) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
@@ -34,10 +38,25 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
{ value: "week", label: t("近一周") },
|
||||
{ value: "month", label: t("近一个月") },
|
||||
];
|
||||
const quickPresets: Array<{ value: QuickPreset; label: string }> = [
|
||||
{ value: "four-hours", label: t("近4小时") },
|
||||
{ value: "twenty-four-hours", label: t("近24小时") },
|
||||
];
|
||||
const granularityOptions = [
|
||||
{ bucketMs: 60_000, label: t("1分钟") },
|
||||
{ bucketMs: 15 * 60_000, label: t("15分钟") },
|
||||
{ bucketMs: 30 * 60_000, label: t("30分钟") },
|
||||
{ bucketMs: 60 * 60_000, label: t("1小时") },
|
||||
];
|
||||
const customButton = useRef<HTMLButtonElement>(null);
|
||||
const popover = useRef<HTMLDivElement>(null);
|
||||
const fourHourButton = useRef<HTMLButtonElement>(null);
|
||||
const granularityMenu = useRef<HTMLDivElement>(null);
|
||||
const popoverId = useId();
|
||||
const granularityMenuId = useId();
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
|
||||
const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 });
|
||||
const [granularityOpen, setGranularityOpen] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!customOpen || !customButton.current || !popover.current) return;
|
||||
@@ -53,11 +72,28 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [customOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!granularityOpen || !fourHourButton.current || !granularityMenu.current) return;
|
||||
return autoUpdate(fourHourButton.current, granularityMenu.current, () =>
|
||||
void computePosition(fourHourButton.current!, granularityMenu.current!, {
|
||||
placement: "right-start",
|
||||
middleware: [offset(4), flip({ padding: 10 }), shift({ padding: 10 })],
|
||||
}).then(({ x, y }) => setMenuPosition({ left: x, top: y })));
|
||||
}, [granularityOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) setGranularityOpen(false);
|
||||
}, [customOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) return;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
|
||||
if (
|
||||
!customButton.current?.contains(target)
|
||||
&& !popover.current?.contains(target)
|
||||
&& !granularityMenu.current?.contains(target)
|
||||
) onCustomOpenChange(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
@@ -66,6 +102,10 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
const parsedStart = parseTimeInput(customStart);
|
||||
const parsedEnd = parseTimeInput(customEnd);
|
||||
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
||||
const closeGranularity = () => {
|
||||
setGranularityOpen(false);
|
||||
fourHourButton.current?.focus();
|
||||
};
|
||||
|
||||
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
||||
<div className={styles.presets}>
|
||||
@@ -98,18 +138,70 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (granularityOpen) {
|
||||
closeGranularity();
|
||||
return;
|
||||
}
|
||||
onCustomOpenChange(false);
|
||||
customButton.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.quickPresets} aria-label={t("快捷时间范围")}>
|
||||
{quickPresets.map((preset) => preset.value === "four-hours"
|
||||
? <button
|
||||
key={preset.value}
|
||||
ref={fourHourButton}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-controls={granularityOpen ? granularityMenuId : undefined}
|
||||
aria-expanded={granularityOpen}
|
||||
aria-pressed={quick === "four-hours"}
|
||||
onClick={() => setGranularityOpen((current) => !current)}
|
||||
>{preset.label}</button>
|
||||
: <button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
aria-pressed={quick === "twenty-four-hours"}
|
||||
onClick={() => {
|
||||
setGranularityOpen(false);
|
||||
onQuickSelect(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}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.filterRow}><ModelSelect mode="multiple" label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
</div>
|
||||
</div>, document.body)}
|
||||
{granularityOpen && customOpen && createPortal(<div
|
||||
id={granularityMenuId}
|
||||
ref={granularityMenu}
|
||||
className={styles.granularityMenu}
|
||||
role="menu"
|
||||
aria-label={t("显示粒度")}
|
||||
style={menuPosition}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
closeGranularity();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{granularityOptions.map((option) => <button
|
||||
key={option.bucketMs}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-pressed={quick === "four-hours" && fourHourBucket === option.bucketMs}
|
||||
onClick={() => {
|
||||
setGranularityOpen(false);
|
||||
onQuickSelect(4 * 60 * 60_000, option.bucketMs);
|
||||
}}
|
||||
>{option.label}</button>)}
|
||||
</div>, document.body)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Sortable from "sortablejs";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, editIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TruncatedButton } from "../../shared/ui/TruncatedButton";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
@@ -61,6 +61,7 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
icon={group.icon}
|
||||
defaultOpen={false}
|
||||
onSettings={props.grouping === "provider" ? () => props.onGroupSettings(group) : undefined}
|
||||
>
|
||||
{group.models.map((model) => <ModelListRow
|
||||
@@ -79,9 +80,10 @@ export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
return <div className={styles.modelGroups}>
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
key={`${props.grouping}:${group.pluginId}`}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
defaultOpen={props.grouping === "flat"}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
@@ -109,14 +111,15 @@ function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
|
||||
function CollapsibleGroup({ label, icon, iconSrc, defaultOpen = true, onSettings, children }: {
|
||||
label: string;
|
||||
icon?: IconifyIcon;
|
||||
iconSrc?: string;
|
||||
defaultOpen?: boolean;
|
||||
onSettings?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return <Card className={styles.groupCard}>
|
||||
<div className={styles.groupHeader}>
|
||||
<button
|
||||
@@ -129,7 +132,10 @@ function CollapsibleGroup({ label, icon, iconSrc, onSettings, children }: {
|
||||
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
</button>
|
||||
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>{t("分组设置")}</button>}
|
||||
{onSettings && <button type="button" className={styles.groupSettings} onClick={onSettings}>
|
||||
<Icon icon={editIcon} size="1em" />
|
||||
{t("分组设置")}
|
||||
</button>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupChevron}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -5,7 +6,7 @@ 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 } from "../../shared/ui/Select";
|
||||
import { Combobox, Select, type ComboboxHandle } from "../../shared/ui/Select";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { CursorPresetChips } from "./CursorPresetChips";
|
||||
@@ -55,8 +56,9 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => void;
|
||||
onDiscover: () => Promise<boolean>;
|
||||
}) {
|
||||
const modelCombobox = useRef<ComboboxHandle>(null);
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const setType = (type: ModelType) => {
|
||||
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
|
||||
@@ -89,6 +91,9 @@ 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];
|
||||
@@ -151,7 +156,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 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("可以直接输入模型标识,也可以读取接口返回的模型列表。")}><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("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <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>
|
||||
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.takeoverActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.takeoverStatus {
|
||||
min-width: 39px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -79,6 +93,9 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.groupSettings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: none;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FormField, SecretTextInput, TextInput } from "../../shared/ui/FormContr
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
||||
import { addIcon } from "../../shared/ui/icons";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
@@ -30,6 +31,7 @@ export function CursorSettingsPage() {
|
||||
const [caCommand, setCaCommand] = useState<string | null>(null);
|
||||
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
|
||||
const [deleting, setDeleting] = useState<Model | null>(null);
|
||||
const [confirmDisableTakeover, setConfirmDisableTakeover] = useState(false);
|
||||
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
|
||||
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
|
||||
const [savingAndTesting, setSavingAndTesting] = useState(false);
|
||||
@@ -42,6 +44,8 @@ export function CursorSettingsPage() {
|
||||
const [groupSettingsBusy, setGroupSettingsBusy] = useState(false);
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const cursorTakenOver = cursorHarness?.settings_applied ?? false;
|
||||
const takeoverLabel = cursorTakenOver ? t("关闭接管Cursor") : t("开启接管Cursor");
|
||||
const pluginModels = configuredPluginModels(plugins);
|
||||
const testTargets = [
|
||||
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
|
||||
@@ -84,8 +88,8 @@ export function CursorSettingsPage() {
|
||||
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
|
||||
});
|
||||
};
|
||||
const discover = async () => {
|
||||
if (!draft) return;
|
||||
const discover = async (): Promise<boolean> => {
|
||||
if (!draft) return false;
|
||||
setDiscovering(true);
|
||||
try {
|
||||
const custom_headers = parseHeaders(draft.customHeadersText);
|
||||
@@ -97,8 +101,10 @@ export function CursorSettingsPage() {
|
||||
custom_headers,
|
||||
});
|
||||
setModelOptions([...new Set(result.models)]);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
return false;
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
@@ -123,11 +129,10 @@ export function CursorSettingsPage() {
|
||||
const active = activeModelTests.current.get(modelHash);
|
||||
if (!active || active.cancelling) return;
|
||||
active.cancelling = true;
|
||||
active.controller.abort();
|
||||
try {
|
||||
await api.cancelModelTest(modelHash, active.testId);
|
||||
active.controller.abort();
|
||||
} catch (cause) {
|
||||
active.cancelling = false;
|
||||
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
|
||||
}
|
||||
};
|
||||
@@ -296,19 +301,46 @@ export function CursorSettingsPage() {
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
<PageActions position="left">
|
||||
<div className={styles.takeoverActions}>
|
||||
<span className={styles.takeoverStatus}>{cursorTakenOver ? t("已接管") : t("未接管")}</span>
|
||||
<TooltipTrigger label={takeoverLabel}>
|
||||
<Switch
|
||||
checked={cursorTakenOver}
|
||||
disabled={cursorBusy || (!cursorTakenOver && !caReady)}
|
||||
label={takeoverLabel}
|
||||
onChange={(enabled) => {
|
||||
if (enabled) void appStore.setCursorEnabled(true);
|
||||
else setConfirmDisableTakeover(true);
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{testTargets.length > 0 && <div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
|
||||
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
|
||||
</div>}
|
||||
</div>
|
||||
</PageActions>}
|
||||
</PageActions>
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<ConfirmDialog
|
||||
open={confirmDisableTakeover}
|
||||
title={t("关闭接管Cursor?")}
|
||||
cancelLabel={t("取消")}
|
||||
confirmLabel={t("关闭接管")}
|
||||
onCancel={() => setConfirmDisableTakeover(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmDisableTakeover(false);
|
||||
void appStore.setCursorEnabled(false);
|
||||
}}
|
||||
>
|
||||
<p>{t("关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?")}</p>
|
||||
</ConfirmDialog>
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={discover} />
|
||||
</>}
|
||||
</Modal>
|
||||
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
|
||||
|
||||
@@ -99,13 +99,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
gap: 7px;
|
||||
|
||||
> button {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, getDisabledPluginModelIds, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
@@ -138,17 +138,7 @@ function PluginCard({ plugin, onOpen }: {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const configured = plugin.providers.some((provider) => provider.configured);
|
||||
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => setDisabledModelIds(getDisabledPluginModelIds());
|
||||
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
}, []);
|
||||
|
||||
const modelCount = plugin.providers.reduce(
|
||||
(count, provider) => count + provider.models.filter((m) => !disabledModelIds.has(m.id)).length,
|
||||
0,
|
||||
);
|
||||
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
||||
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
||||
const importResource = plugin.resources.find((resource) => resource.import);
|
||||
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
|
||||
>span {
|
||||
> span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -37,10 +37,6 @@
|
||||
}
|
||||
|
||||
button {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
@@ -101,21 +97,15 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providerCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.providerRow {
|
||||
.providerRow,
|
||||
.resourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
>div:first-child {
|
||||
> div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -128,576 +118,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.resourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.resourceText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.resourceHeaderLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resourceDescription {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metricsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metricBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.metricPrefix {
|
||||
font-weight: 700;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.claudeMetricBadge {
|
||||
background: rgba(234, 88, 12, 0.14);
|
||||
color: #fb923c;
|
||||
border: 1px solid rgba(234, 88, 12, 0.28);
|
||||
}
|
||||
|
||||
.geminiMetricBadge {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||
}
|
||||
|
||||
.genericMetricBadge {
|
||||
background: color-mix(in srgb, var(--vscode-badge-background, #333) 40%, transparent);
|
||||
color: var(--vscode-badge-foreground, #ccc);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
|
||||
}
|
||||
|
||||
.proBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, rgba(234, 88, 12, 0.25), rgba(249, 115, 22, 0.15));
|
||||
color: #fb923c;
|
||||
border: 1px solid rgba(249, 115, 22, 0.4);
|
||||
box-shadow: 0 0 6px rgba(249, 115, 22, 0.15);
|
||||
}
|
||||
|
||||
.freeBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--vscode-badge-background, #333) 30%, transparent);
|
||||
color: var(--vscode-descriptionForeground, #888);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
|
||||
}
|
||||
|
||||
.providerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modelsView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modelsViewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
.backButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 60%, transparent);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent);
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.headerCountChip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-focusBorder, #007fd4);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 30%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelListToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clearSearchBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 20%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelSearchInput {
|
||||
width: 100%;
|
||||
padding: 7px 30px 7px 32px;
|
||||
font-size: type.$font-size-xs;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 70%, transparent);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.modelBatchActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.pillButton {
|
||||
padding: 5px 12px;
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-button-secondaryBackground, #3a3d41) 60%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-button-foreground, #fff);
|
||||
background: var(--vscode-button-secondaryHoverBackground, #45494e);
|
||||
border-color: color-mix(in srgb, var(--vscode-button-secondaryHoverBackground, #45494e) 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelsViewList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptySearch {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.modelItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, var(--vscode-editor-background));
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 70%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 50%, transparent);
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18);
|
||||
background: color-mix(in srgb, var(--vscode-sideBar-background) 80%, var(--vscode-focusBorder, #007fd4) 6%);
|
||||
}
|
||||
}
|
||||
|
||||
.modelItemActive {
|
||||
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 7%, var(--vscode-editor-background));
|
||||
}
|
||||
|
||||
.modelItemDisabled {
|
||||
opacity: 0.45;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-sideBar-border) 40%, transparent);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modelCheckbox {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--vscode-focusBorder, #007fd4);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.modelInfo {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
font-weight: 500;
|
||||
color: var(--vscode-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelId {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelBadges {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.claudeTag,
|
||||
.geminiTag,
|
||||
.gptTag,
|
||||
.thinkingTag,
|
||||
.highTag,
|
||||
.mediumTag,
|
||||
.lowTag,
|
||||
.extraLowTag,
|
||||
.imageTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.claudeTag {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
.geminiTag {
|
||||
color: #60a5fa;
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border: 1px solid rgba(96, 165, 250, 0.35);
|
||||
}
|
||||
|
||||
.gptTag {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
border: 1px solid rgba(52, 211, 153, 0.35);
|
||||
}
|
||||
|
||||
.thinkingTag {
|
||||
color: #c084fc;
|
||||
background: rgba(192, 132, 252, 0.12);
|
||||
border: 1px solid rgba(192, 132, 252, 0.35);
|
||||
}
|
||||
|
||||
.highTag {
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.mediumTag {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.lowTag {
|
||||
color: #2dd4bf;
|
||||
background: rgba(45, 212, 191, 0.12);
|
||||
border: 1px solid rgba(45, 212, 191, 0.35);
|
||||
}
|
||||
|
||||
.extraLowTag {
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
.imageTag {
|
||||
color: #f472b6;
|
||||
background: rgba(244, 114, 182, 0.12);
|
||||
border: 1px solid rgba(244, 114, 182, 0.35);
|
||||
}
|
||||
|
||||
.resourceIdentity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.resourceText {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.resourceRowDisabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.resourceActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionIconButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
color: var(--vscode-textLink-foreground, #3b82f6);
|
||||
background: color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 30%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: var(--vscode-textLink-foreground, #3b82f6);
|
||||
border-color: var(--vscode-textLink-foreground, #3b82f6);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.refreshingSpin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.actionDeleteButton {
|
||||
color: #ef4444;
|
||||
background: color-mix(in srgb, #ef4444 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.readyBadge,
|
||||
.coolingBadge,
|
||||
.invalidBadge,
|
||||
.disabledBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.badgeDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.readyBadge {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 30%, transparent);
|
||||
|
||||
.badgeDot {
|
||||
background: var(--vscode-testing-iconPassed, #73c991);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 60%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.coolingBadge {
|
||||
color: var(--vscode-editorWarning-foreground, #cca700);
|
||||
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 30%, transparent);
|
||||
|
||||
.badgeDot {
|
||||
background: var(--vscode-editorWarning-foreground, #cca700);
|
||||
}
|
||||
}
|
||||
|
||||
.invalidBadge {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 30%, transparent);
|
||||
|
||||
.badgeDot {
|
||||
background: var(--vscode-errorForeground, #f48771);
|
||||
}
|
||||
}
|
||||
|
||||
.disabledBadge {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
|
||||
|
||||
.badgeDot {
|
||||
background: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
||||
}
|
||||
@@ -733,4 +153,4 @@
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
getDisabledPluginAccountIds,
|
||||
getDisabledPluginModelIds,
|
||||
setPluginAccountEnabled,
|
||||
setPluginModelEnabled,
|
||||
setMultiplePluginModelsEnabled,
|
||||
pluginText,
|
||||
type PluginAddMethod,
|
||||
type PluginDescriptor,
|
||||
@@ -19,10 +14,6 @@ import { appStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { refreshIcon, trashIcon } from "../../shared/ui/icons";
|
||||
import { Switch } from "../../shared/ui/Switch";
|
||||
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
||||
import styles from "./PluginResourcePanels.module.scss";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -118,7 +109,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
|
||||
setBegun(next);
|
||||
setStatus("polling");
|
||||
await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
if (next.userCode) await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
@@ -126,23 +117,14 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
}
|
||||
};
|
||||
|
||||
const userCode = begun?.userCode;
|
||||
return <Card className={styles.methodCard}>
|
||||
<strong>{pluginText(method.displayName, locale)}</strong>
|
||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||
{userCode && status === "polling" && <div className={styles.deviceCode}>
|
||||
<small>{t("设备验证码")}</small>
|
||||
<button
|
||||
type="button"
|
||||
title={begun.verificationUrlComplete || begun.verificationUrl || begun.userCode}
|
||||
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
|
||||
>
|
||||
{begun.userCode.startsWith("http") ? t("授权链接") : begun.userCode}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.copy}
|
||||
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
|
||||
>
|
||||
<button type="button" onClick={() => void copyCode(userCode)}>{userCode}</button>
|
||||
<button type="button" className={styles.copy} onClick={() => void copyCode(userCode)}>
|
||||
{copied ? t("已复制") : t("复制")}
|
||||
</button>
|
||||
</div>}
|
||||
@@ -160,7 +142,6 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null);
|
||||
|
||||
const run = async (key: string, task: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
@@ -175,40 +156,6 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const autoRefreshResources = async () => {
|
||||
let didRefresh = false;
|
||||
for (const res of plugin.resources) {
|
||||
if (!res.canRefresh) continue;
|
||||
for (const item of res.resources) {
|
||||
if (!active) return;
|
||||
// Auto-refresh if resource is marked invalid or missing metrics
|
||||
if (item.state.status === "invalid" || item.metrics.length === 0) {
|
||||
try {
|
||||
await api.refreshPluginResource(plugin.id, res.type, item.id);
|
||||
didRefresh = true;
|
||||
} catch {
|
||||
// ignore background refresh failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (active && didRefresh) {
|
||||
await appStore.refreshPlugins();
|
||||
}
|
||||
};
|
||||
void autoRefreshResources();
|
||||
return () => { active = false; };
|
||||
}, [plugin.id]);
|
||||
|
||||
if (activeProviderId) {
|
||||
const provider = plugin.providers.find((p) => p.id === activeProviderId);
|
||||
if (provider) {
|
||||
return <ProviderModelsView provider={provider} onBack={() => setActiveProviderId(null)} />;
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={styles.panel}>
|
||||
{plugin.providers.map((provider) => <ProviderRow
|
||||
key={provider.id}
|
||||
@@ -218,12 +165,11 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
onSync={() => void run(`sync:${provider.id}`, async () => {
|
||||
await api.syncPluginModels(plugin.id, provider.id);
|
||||
})}
|
||||
onViewModels={() => setActiveProviderId(provider.id)}
|
||||
/>)}
|
||||
{plugin.resources.map((resource) => <ResourceList
|
||||
key={resource.type}
|
||||
resource={resource}
|
||||
busyKey={busy}
|
||||
busy={busy !== null}
|
||||
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
||||
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
@@ -235,190 +181,33 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProviderRow({ provider, busy, syncing, onSync, onViewModels }: {
|
||||
function ProviderRow({ provider, busy, syncing, onSync }: {
|
||||
provider: PluginProviderDescriptor;
|
||||
busy: boolean;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
onViewModels: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
|
||||
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
}, []);
|
||||
|
||||
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
|
||||
|
||||
return (
|
||||
<Card className={styles.providerCard}>
|
||||
<div className={styles.providerRow}>
|
||||
<div>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>
|
||||
{provider.providerType}
|
||||
{" · "}
|
||||
{provider.models.length > 0
|
||||
? `${enabledCount}/${t("{count} 个模型", { count: provider.models.length })}`
|
||||
: t("尚未同步模型")}
|
||||
{" · "}
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.providerActions}>
|
||||
{provider.models.length > 0 && (
|
||||
<Button size="small" onClick={onViewModels}>
|
||||
{t("查看模型")}
|
||||
</Button>
|
||||
)}
|
||||
{provider.hasModels && (
|
||||
<Button size="small" variant="primary" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelsView({ provider, onBack }: { provider: PluginProviderDescriptor; onBack: () => void }) {
|
||||
const { locale } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
|
||||
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||
}, []);
|
||||
|
||||
const toggleModel = (modelId: string) => {
|
||||
const isCurrentlyDisabled = disabledIds.has(modelId);
|
||||
setPluginModelEnabled(modelId, isCurrentlyDisabled);
|
||||
setDisabledIds(getDisabledPluginModelIds());
|
||||
};
|
||||
|
||||
const toggleAll = (enable: boolean) => {
|
||||
const target = filteredModels.length > 0 ? filteredModels : provider.models;
|
||||
setMultiplePluginModelsEnabled(target.map((m) => m.id), enable);
|
||||
setDisabledIds(getDisabledPluginModelIds());
|
||||
};
|
||||
|
||||
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
|
||||
|
||||
const filteredModels = provider.models.filter((m) => {
|
||||
if (!search.trim()) return true;
|
||||
const q = search.toLowerCase();
|
||||
const shortId = m.id.split("/").pop() || m.id;
|
||||
return m.displayName.toLowerCase().includes(q) || shortId.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.modelsView}>
|
||||
<div className={styles.modelsViewHeader}>
|
||||
<TooltipTrigger label={t("返回")}>
|
||||
<button type="button" className={styles.backButton} onClick={onBack} aria-label={t("返回")}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<div className={styles.headerTitle}>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>{t("模型列表")}</span>
|
||||
<span className={styles.headerCountChip}>
|
||||
{enabledCount}/{provider.models.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modelListToolbar}>
|
||||
<div className={styles.searchContainer}>
|
||||
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("搜索模型…")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className={styles.modelSearchInput}
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className={styles.clearSearchBtn} onClick={() => setSearch("")} aria-label="Clear">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.modelBatchActions}>
|
||||
<button type="button" onClick={() => toggleAll(true)} className={styles.pillButton}>
|
||||
{t("全选")}
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleAll(false)} className={styles.pillButton}>
|
||||
{t("全不选")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modelsViewList}>
|
||||
{filteredModels.map((m) => {
|
||||
const shortId = m.id.split("/").pop() || m.id;
|
||||
const nameLower = m.displayName.toLowerCase();
|
||||
const idLower = m.id.toLowerCase();
|
||||
const isEnabled = !disabledIds.has(m.id);
|
||||
|
||||
const isClaude = nameLower.includes("claude") || idLower.includes("claude");
|
||||
const isGemini = nameLower.includes("gemini") || idLower.includes("gemini");
|
||||
const isGpt = nameLower.includes("gpt") || idLower.includes("gpt");
|
||||
const isThinking = nameLower.includes("thinking") || idLower.includes("thinking");
|
||||
const isHigh = nameLower.includes("(high)") || idLower.includes("-high");
|
||||
const isMedium = nameLower.includes("(medium)") || idLower.includes("-medium");
|
||||
const isLow = nameLower.includes("(low)") || idLower.includes("-low");
|
||||
const isExtraLow = nameLower.includes("(extra-low)") || idLower.includes("-extra-low");
|
||||
const isImage = nameLower.includes("image") || idLower.includes("image");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`${styles.modelItem} ${isEnabled ? styles.modelItemActive : styles.modelItemDisabled}`}
|
||||
onClick={() => toggleModel(m.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={() => toggleModel(m.id)}
|
||||
className={styles.modelCheckbox}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className={styles.modelInfo}>
|
||||
<span className={styles.modelName}>{pluginText(m.displayName, locale) || shortId}</span>
|
||||
<span className={styles.modelId}>{shortId}</span>
|
||||
</div>
|
||||
<div className={styles.modelBadges}>
|
||||
{isClaude && <span className={styles.claudeTag}>Claude</span>}
|
||||
{isGemini && <span className={styles.geminiTag}>Gemini</span>}
|
||||
{isGpt && <span className={styles.gptTag}>GPT-OSS</span>}
|
||||
{isThinking && <span className={styles.thinkingTag}>Thinking</span>}
|
||||
{isHigh && <span className={styles.highTag}>High</span>}
|
||||
{isMedium && <span className={styles.mediumTag}>Medium</span>}
|
||||
{isLow && <span className={styles.lowTag}>Low</span>}
|
||||
{isExtraLow && <span className={styles.extraLowTag}>Extra-Low</span>}
|
||||
{isImage && <span className={styles.imageTag}>Image</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && (
|
||||
<div className={styles.emptySearch}>
|
||||
<span>{t("暂无数据")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return <Card className={styles.providerRow}>
|
||||
<div>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>
|
||||
{provider.providerType}
|
||||
{" · "}
|
||||
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
|
||||
{" · "}
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
|
||||
function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
resource: PluginResourceDescriptor;
|
||||
busyKey: string | null;
|
||||
busy: boolean;
|
||||
onRefresh: (item: PluginResourceView) => void;
|
||||
onDelete: (item: PluginResourceView) => void;
|
||||
}) {
|
||||
@@ -440,20 +229,14 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
|
||||
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>}
|
||||
<div className={styles.resourceList}>
|
||||
{visible.map((item) => {
|
||||
const isRefreshing = busyKey === `refresh:${item.id}`;
|
||||
const isDeleting = busyKey === `delete:${item.id}`;
|
||||
return <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canRefresh={resource.canRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
isDeleting={isDeleting}
|
||||
disabled={isRefreshing || isDeleting}
|
||||
onRefresh={() => onRefresh(item)}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>;
|
||||
})}
|
||||
{visible.map((item) => <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canRefresh={resource.canRefresh}
|
||||
disabled={busy}
|
||||
onRefresh={() => onRefresh(item)}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>)}
|
||||
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
||||
</div>
|
||||
{pageCount > 1 && <div className={styles.pagination}>
|
||||
@@ -465,116 +248,40 @@ function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
function ResourceRow({ item, canRefresh, isRefreshing, isDeleting, disabled, onRefresh, onDelete }: {
|
||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
item: PluginResourceView;
|
||||
canRefresh: boolean;
|
||||
isRefreshing: boolean;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [disabledAccountIds, setDisabledAccountIds] = useState<Set<string>>(() => getDisabledPluginAccountIds());
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => setDisabledAccountIds(getDisabledPluginAccountIds());
|
||||
window.addEventListener("cursor_plugin_accounts_changed", handleUpdate);
|
||||
return () => window.removeEventListener("cursor_plugin_accounts_changed", handleUpdate);
|
||||
}, []);
|
||||
|
||||
const isEnabled = !disabledAccountIds.has(item.id);
|
||||
|
||||
const toggleAccount = (checked: boolean) => {
|
||||
setPluginAccountEnabled(item.id, checked);
|
||||
setDisabledAccountIds(getDisabledPluginAccountIds());
|
||||
};
|
||||
|
||||
return <Card className={`${styles.resourceRow} ${!isEnabled ? styles.resourceRowDisabled : ""}`}>
|
||||
<div className={styles.resourceIdentity}>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={disabled}
|
||||
label={item.displayName}
|
||||
onChange={toggleAccount}
|
||||
/>
|
||||
<div className={styles.resourceText}>
|
||||
<div className={styles.resourceHeaderLine}>
|
||||
<strong>{item.displayName}</strong>
|
||||
{item.description && (() => {
|
||||
const desc = pluginText(item.description, locale).trim();
|
||||
const isPro = desc.toLowerCase().includes("pro") || desc.toLowerCase().includes("ultra") || desc.toLowerCase().includes("premium") || desc.toLowerCase().includes("advanced");
|
||||
const label = isPro ? (desc.toLowerCase().includes("ultra") ? "ULTRA" : "PRO") : "FREE";
|
||||
return <span className={isPro ? styles.proBadge : styles.freeBadge}>{isPro ? `🔥 ${label}` : label}</span>;
|
||||
})()}
|
||||
</div>
|
||||
{item.metrics.length > 0 && (
|
||||
<div className={styles.metricsRow}>
|
||||
{item.metrics.map((metric) => {
|
||||
const isClaude = metric.id.toLowerCase().includes("claude");
|
||||
const isGemini = metric.id.toLowerCase().includes("gemini");
|
||||
const badgeStyle = isClaude
|
||||
? styles.claudeMetricBadge
|
||||
: isGemini
|
||||
? styles.geminiMetricBadge
|
||||
: styles.genericMetricBadge;
|
||||
const shortLabel = isClaude ? "C" : isGemini ? "G" : pluginText(metric.label, locale);
|
||||
return (
|
||||
<span
|
||||
key={metric.id}
|
||||
className={`${styles.metricBadge} ${badgeStyle}`}
|
||||
title={metric.resetAtMs ? `${pluginText(metric.label, locale)} Reset: ${new Date(metric.resetAtMs).toLocaleTimeString()}` : `${pluginText(metric.label, locale)} Quota: ${Math.round(metric.value)}%`}
|
||||
>
|
||||
<span className={styles.metricPrefix}>{shortLabel}</span>
|
||||
<span className={styles.metricValue}>{Math.round(metric.value)}%</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return <Card className={styles.resourceRow}>
|
||||
<div>
|
||||
<strong>{item.displayName}</strong>
|
||||
{item.description && <span>{pluginText(item.description, locale)}</span>}
|
||||
{item.metrics.map((metric) => <span key={metric.id}>
|
||||
{metric.unit === "percent"
|
||||
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
|
||||
: `${pluginText(metric.label, locale)}: ${metric.value}`}
|
||||
</span>)}
|
||||
</div>
|
||||
<div className={styles.resourceActions}>
|
||||
<StateBadge isEnabled={isEnabled} state={item.state} />
|
||||
{canRefresh && (
|
||||
<TooltipTrigger label={t("刷新")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionIconButton} ${isRefreshing ? styles.refreshingSpin : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={onRefresh}
|
||||
aria-label={t("刷新")}
|
||||
>
|
||||
<Icon icon={refreshIcon} size="1.05em" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
<TooltipTrigger label={t("删除")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionIconButton} ${styles.actionDeleteButton}`}
|
||||
disabled={disabled || isDeleting}
|
||||
onClick={onDelete}
|
||||
aria-label={t("删除")}
|
||||
>
|
||||
<Icon icon={trashIcon} size="1.05em" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<div className={styles.actions}>
|
||||
<StateBadge state={item.state} />
|
||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function StateBadge({ isEnabled = true, state }: { isEnabled?: boolean; state: PluginResourceView["state"] }) {
|
||||
if (!isEnabled) {
|
||||
return <span className={styles.disabledBadge}><span className={styles.badgeDot} />{t("已停用")}</span>;
|
||||
}
|
||||
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
|
||||
if (state.status === "cooling") {
|
||||
return <span className={styles.coolingBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("冷却中")}</span>;
|
||||
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
||||
}
|
||||
if (state.status === "invalid") {
|
||||
return <span className={styles.invalidBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("已失效")}</span>;
|
||||
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
|
||||
}
|
||||
return <span className={styles.readyBadge}><span className={styles.badgeDot} />{t("可用")}</span>;
|
||||
return <span className={styles.ready}>{t("可用")}</span>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
|
||||
@@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() {
|
||||
const nextVersion = await updateStore.check();
|
||||
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
message(t("检查更新失败:{error}", { error }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() {
|
||||
try {
|
||||
await updateStore.install();
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
const error = cause instanceof Error ? cause.message : String(cause);
|
||||
message(t("安装更新失败:{error}", { error }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.textButton {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.promptEditor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||
border-radius: 6px;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.select { width: 68%; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { api, pluginText, type CommitSettingsView } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { useAppStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { ModelSelect, type ModelSelectOption } from "../../shared/ui/ModelSelect";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import controls from "../../shared/ui/Controls.module.scss";
|
||||
import { modelProviderName } from "../../shared/utils/modelProvider";
|
||||
import styles from "./CommitSettingsCard.module.scss";
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
export function CommitSettingsCard() {
|
||||
const { models, plugins } = useAppStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
const [view, setView] = useState<CommitSettingsView | null>(null);
|
||||
const [modelDraft, setModelDraft] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [savingModel, setSavingModel] = useState(false);
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [promptDraft, setPromptDraft] = useState("");
|
||||
const [savingPrompt, setSavingPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
let loaded = await api.commitSettings(locale);
|
||||
if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) {
|
||||
loaded = await api.setCommitSettings({
|
||||
model_id: loaded.model_id,
|
||||
prompt: "",
|
||||
prompt_locale: locale,
|
||||
});
|
||||
}
|
||||
if (active) {
|
||||
setView(loaded);
|
||||
setModelDraft(loaded.model_id);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (active) message(errorText(cause));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [locale, message]);
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
const options: ModelSelectOption[] = [{ value: "", label: t("直连"), group: "Cursor" }];
|
||||
const seen = new Set<string>();
|
||||
for (const model of models) {
|
||||
seen.add(model.model_hash);
|
||||
options.push({
|
||||
value: model.model_hash,
|
||||
label: model.display_name && model.display_name !== model.model_id
|
||||
? `${model.display_name}(${model.model_id})`
|
||||
: model.display_name || model.model_id,
|
||||
group: modelProviderName(model),
|
||||
icon: model.type === "anthropic" ? claudeIcon : openAiIcon,
|
||||
});
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
for (const provider of plugin.providers) {
|
||||
if (!provider.configured) continue;
|
||||
const group = pluginText(provider.displayName, locale) || plugin.name;
|
||||
for (const model of provider.models) {
|
||||
seen.add(model.id);
|
||||
options.push({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
group,
|
||||
iconSrc: model.icon || undefined,
|
||||
icon: model.icon ? undefined : flatColorOrganizationIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (view?.model_id && !seen.has(view.model_id)) {
|
||||
options.push({ value: view.model_id, label: view.model_id, group: "Cursor" });
|
||||
}
|
||||
return options;
|
||||
}, [locale, models, plugins, view]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (modelId: string, prompt: string) => {
|
||||
if (!view) return null;
|
||||
const normalizedPrompt =
|
||||
prompt.trim() === view.default_prompt.trim() ? "" : prompt.trim();
|
||||
return api.setCommitSettings({
|
||||
model_id: modelId,
|
||||
prompt: normalizedPrompt,
|
||||
prompt_locale: locale,
|
||||
});
|
||||
},
|
||||
[view, locale],
|
||||
);
|
||||
|
||||
const editModel = useCallback(() => {
|
||||
if (!view) return;
|
||||
setModelDraft(view.model_id);
|
||||
setEditing(true);
|
||||
}, [view]);
|
||||
|
||||
const cancelModelEdit = useCallback(() => {
|
||||
setModelDraft(view?.model_id ?? "");
|
||||
setEditing(false);
|
||||
}, [view]);
|
||||
|
||||
const saveModel = useCallback(async () => {
|
||||
if (!view) return;
|
||||
setSavingModel(true);
|
||||
try {
|
||||
const saved = await persist(modelDraft, view.prompt);
|
||||
if (saved) {
|
||||
setView(saved);
|
||||
setModelDraft(saved.model_id);
|
||||
setEditing(false);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingModel(false);
|
||||
}
|
||||
}, [modelDraft, view, persist, message]);
|
||||
|
||||
const openPrompt = useCallback(() => {
|
||||
if (!view) return;
|
||||
setPromptDraft(view.prompt || view.default_prompt);
|
||||
setPromptOpen(true);
|
||||
}, [view]);
|
||||
|
||||
const savePrompt = useCallback(async () => {
|
||||
if (!view) return;
|
||||
setSavingPrompt(true);
|
||||
try {
|
||||
const saved = await persist(view.model_id, promptDraft);
|
||||
if (saved) setView(saved);
|
||||
setPromptOpen(false);
|
||||
message(t("提示词设置已保存"));
|
||||
} catch (cause) {
|
||||
message(errorText(cause));
|
||||
} finally {
|
||||
setSavingPrompt(false);
|
||||
}
|
||||
}, [view, promptDraft, persist, message]);
|
||||
|
||||
const resetPrompt = useCallback(() => {
|
||||
if (!view) return;
|
||||
setPromptDraft(view.default_prompt);
|
||||
}, [view]);
|
||||
|
||||
const selectedModelLabel = modelOptions.find((option) => option.value === view?.model_id)?.label
|
||||
?? view?.model_id
|
||||
?? t("加载中…");
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={savingModel} onClick={cancelModelEdit}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={savingModel} onClick={() => void saveModel()}>
|
||||
{savingModel ? t("保存中…") : t("保存")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.actionGroup}>
|
||||
<button type="button" className={styles.textButton} disabled={!view} onClick={openPrompt}>
|
||||
{t("提示词设置")}
|
||||
</button>
|
||||
<button type="button" className={styles.textButton} disabled={!view} onClick={editModel}>
|
||||
{t("编辑")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitledCard title={t("Commit 提交代码模型设置")} action={action}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.details}>
|
||||
<strong>{t("生成模型")}</strong>
|
||||
</div>
|
||||
{editing ? <div className={styles.select}>
|
||||
<ModelSelect
|
||||
mode="single"
|
||||
value={modelDraft}
|
||||
options={modelOptions}
|
||||
disabled={savingModel}
|
||||
label={t("生成模型")}
|
||||
onChange={setModelDraft}
|
||||
/>
|
||||
</div> : <span className={styles.value}>{selectedModelLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
|
||||
<Modal
|
||||
open={promptOpen}
|
||||
title={t("提示词设置")}
|
||||
wide
|
||||
fullHeight
|
||||
busy={savingPrompt}
|
||||
onClose={() => setPromptOpen(false)}
|
||||
onSubmit={() => void savePrompt()}
|
||||
submitLabel={t("保存")}
|
||||
closeLabel={t("取消")}
|
||||
secondaryAction={
|
||||
<button type="button" className={controls.secondary} onClick={resetPrompt}>
|
||||
{t("恢复默认")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
className={styles.promptEditor}
|
||||
value={promptDraft}
|
||||
spellCheck={false}
|
||||
aria-label={t("提交信息提示词")}
|
||||
onChange={(event) => setPromptDraft(event.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ProxySettingsCard({
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const custom = draft.mode === "custom";
|
||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
|
||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "default" ? 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: "system", 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: "default", 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}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorag
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { LegacyModelImport } from "../models/LegacyModelImport";
|
||||
import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard";
|
||||
import { CommitSettingsCard } from "./CommitSettingsCard";
|
||||
import { ProxySettingsCard } from "./ProxySettingsCard";
|
||||
import { TabSettingsCard } from "./TabSettingsCard";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
@@ -30,7 +31,7 @@ export function SettingsPage() {
|
||||
const [clearScope, setClearScope] = useState<StatisticsStorageScope>("details");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [outboundProxy, setOutboundProxy] = useState<ProxySettings | null>(null);
|
||||
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
|
||||
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "default", address: "", auth_enabled: false, username: "", password: "" });
|
||||
const [editingProxy, setEditingProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
|
||||
@@ -229,6 +230,7 @@ export function SettingsPage() {
|
||||
</TitledCard>
|
||||
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
|
||||
<TabSettingsCard settings={tabSettings} draft={tabDraft} editing={editingTab} saving={savingTab} onDraftChange={setTabDraft} onEdit={editTab} onCancel={cancelTabEdit} onSave={() => void saveTab()} />
|
||||
<CommitSettingsCard />
|
||||
<AppLifecycleSettingsCard />
|
||||
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
|
||||
<div className={styles.importRow}>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
width: min(320px, 50%);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
"028c60a8a8e30a1b": "Page {page} / {total}",
|
||||
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Edit",
|
||||
"0525054436acdb6e": "Commit message prompt",
|
||||
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
|
||||
"0580e0a99a6f1afc": "Artifacts",
|
||||
"06619f339fa0ab46": "Preparing the plugin runtime",
|
||||
@@ -26,11 +27,10 @@
|
||||
"0e41f8e3d59ec47b": "Storage management",
|
||||
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
||||
"0ec1e85b0c3cfa65": "Call details",
|
||||
"0fa9ab1b5cdaca63": "Authorization URL",
|
||||
"100fad4a0b3ab781": "Last 24 hours",
|
||||
"105a9082c346f958": "Testing…",
|
||||
"124be3f86f197802": "Token usage",
|
||||
"12ae77e6202d063e": "Custom Headers",
|
||||
"12b86e89701b0241": "Enter a model name and press Enter to add it",
|
||||
"133340e53175128a": "Test all",
|
||||
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
|
||||
"13b61c5f697b6700": "Cache hit rate",
|
||||
@@ -41,6 +41,7 @@
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18165f8865eacc91": "No plugins installed",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"19bb071f8c362695": "Prompt settings",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
|
||||
"1aa65c55c6cc6163": "Device code",
|
||||
@@ -63,11 +64,13 @@
|
||||
"24a0a24864454575": "Existing, skipped",
|
||||
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
|
||||
"29585d7193539200": "Current version {version}",
|
||||
"296da37506328d89": "Disable Cursor takeover",
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cbc58108d78b06c": "Waiting for browser authorization…",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2cdf7d3275016cc2": "Disable Cursor takeover?",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
|
||||
@@ -76,7 +79,6 @@
|
||||
"2f5f1d6fbfb061ed": "Not set",
|
||||
"2f6416a2c424856b": "Final request URL",
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"2fe5a8d0eee9f14c": "Invalid",
|
||||
"303c30f301514250": "Search resources",
|
||||
@@ -90,7 +92,6 @@
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"38844b135cf70dfc": "More",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
"3a0fb74abe2460b6": "Chunks",
|
||||
"3a3f595df70ec8ff": "Clear storage",
|
||||
@@ -107,6 +108,7 @@
|
||||
"402495402ce333b1": "Reinitialize plugins",
|
||||
"40a08e7cf320ae07": "Clear detailed records?",
|
||||
"4125fc7ba333524c": "Default light",
|
||||
"4256064de039de89": "Display granularity",
|
||||
"42655ed8e4108ae2": "Input (non-cached)",
|
||||
"42a1d9e5b037c210": "Bytes",
|
||||
"42aa8e01e98c0d8c": "Total duration",
|
||||
@@ -130,17 +132,19 @@
|
||||
"4d99c976beb8827e": "Ready",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4eafa9e925b30bcd": "Custom",
|
||||
"4f0f45b0312ca4ba": "1 hour",
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
|
||||
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "Taken over",
|
||||
"5401344227e49e2f": "TAB settings",
|
||||
"54644705e9c61009": "Port settings",
|
||||
"54c53e5fe791d1f3": "Initialize CA",
|
||||
"54e6745ff43c9c74": "Unable to save model order",
|
||||
"550eddc3c7fefa99": "Sponsored",
|
||||
"552a5d4baf45d878": "Commit Message Model Settings",
|
||||
"56432ba297009bdc": "Initialize the CA first",
|
||||
"56627c94a9decee6": "Maximum output tokens",
|
||||
"572cf45ba43634b3": "Back",
|
||||
"576d81bb0631b165": "Import",
|
||||
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
||||
"59346e82b3dd2998": "TAB service address",
|
||||
@@ -155,12 +159,13 @@
|
||||
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
|
||||
"5fd2ec5a6e9b654c": "Total: {cost}",
|
||||
"6078a681a306930d": "Cache write",
|
||||
"609640f72d422b57": "Quick time ranges",
|
||||
"61a4c7bac12dc125": "Failed calls: {count}",
|
||||
"61c7d1f758b647d5": "Call information",
|
||||
"621f63a5f08384ac": "Cache read: {tokens}",
|
||||
"6320b4a8722a851f": "Status",
|
||||
"63c73c4730f4473e": "Apply",
|
||||
"63d90d977348ab1f": "Copy",
|
||||
"63d90d977348ab1f": "Duplicate",
|
||||
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
|
||||
"651f274470153a05": "Software updates",
|
||||
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
|
||||
@@ -177,7 +182,6 @@
|
||||
"6ae80538c2b2572d": "Minimize window",
|
||||
"6d1876364ac6457d": "Proxy mode",
|
||||
"6e86570183c3cdd0": "You're up to date",
|
||||
"6f281e1d8380a055": "Hide models",
|
||||
"7005693f4f050bce": "Cache I/O {cost}",
|
||||
"72644ec4389da2f7": "Default layout",
|
||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||
@@ -198,13 +202,14 @@
|
||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||
"80a57e03f0717f91": "Not configured",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"8135034002c770ac": "View models",
|
||||
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "Fetch models",
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"844b8cc8dff7c1d8": "Default",
|
||||
"859757ea792a73da": "30 minutes",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86de7c4ee8fa7689": "Sync models",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
@@ -218,19 +223,21 @@
|
||||
"8d0c47eb9eac2d34": "Call type",
|
||||
"8df48894086d6fbd": "Reason (optional)",
|
||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||
"8ec7b052f694e3c0": "Search models…",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||
"91aaf184cfc17ffd": "Overview",
|
||||
"91af6e57e7453fbe": "Add",
|
||||
"91af6e57e7453fbe": "Add account",
|
||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||
"92e26b27d5ea8f0e": "Failed to check for updates: {error}",
|
||||
"9305c0e13642cab4": "Not taken over",
|
||||
"940a168911ade998": "Items per page",
|
||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"954ec984cd4f49d1": "Syncing…",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"96f7642963ca0dbf": "Enable Cursor takeover",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
@@ -264,7 +271,7 @@
|
||||
"a748cc074f78de00": "View details",
|
||||
"a7617f42f898b2bf": "Use complete request URL",
|
||||
"a8036485f9227f2c": "Drag to reorder",
|
||||
"a8c3698b5b8c485d": "Disabled",
|
||||
"a80b53f8848e6d27": "Failed to install update: {error}",
|
||||
"a98585871c5313ff": "Display name",
|
||||
"ab9084a640fbb864": "Deselect all",
|
||||
"abecab6701177721": "Launch at login enabled",
|
||||
@@ -291,6 +298,7 @@
|
||||
"b8c9b486c83b5778": "Hide ad",
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"ba2e93e73037c71e": "Restore default",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"ba6403d22876d626": "Cooling down",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
@@ -330,14 +338,16 @@
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d71b0171c44b668a": "Disabling this will remove Cursor's local proxy configuration. If you need to sign in to an official account, you usually do not need to disable it. You can sign in directly because BYOK models and official-account models now work together seamlessly. Do you want to continue disabling and clearing the proxy configuration?",
|
||||
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
|
||||
"d7e266bdc8064193": "Group name",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
"d8c589c455675b46": "Prompt settings saved",
|
||||
"da521d1c1cbd36af": "Authorization is required to install the certificate",
|
||||
"da7ae985487c38e6": "Last hour",
|
||||
"daede9881787abe7": "Notes",
|
||||
"db340a9896306d08": "Test cancelled",
|
||||
"dbaa1c565cb1f108": "15 minutes",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
@@ -358,6 +368,7 @@
|
||||
"e24096c81b1a8af4": "Authorization was denied or failed.",
|
||||
"e24ebe4a866d69bf": "Test failed: {error}",
|
||||
"e25bf3f419bb68f0": "Call history",
|
||||
"e2ebc59779f012ed": "Generation model",
|
||||
"e3fee05f688708b4": "LLM calls",
|
||||
"e4760c6a1df24f17": "Complete request URL",
|
||||
"e5043c7a2b408271": "Last 10 minutes",
|
||||
@@ -367,14 +378,14 @@
|
||||
"e77e3d58b0dcffaa": "Duration",
|
||||
"e825a2a42c22380e": "Model type",
|
||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||
"e85373502c372776": "1 minute",
|
||||
"e8b1268c1e3610f2": "Existing",
|
||||
"ea26b760e930a7ca": "Call observability",
|
||||
"ea4bf042d1bc5eb8": "Models",
|
||||
"eb11e2df1d8ae387": "Provider URL",
|
||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
||||
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||
"eba54690937bc532": "Manage",
|
||||
"eba54690937bc532": "Manage accounts",
|
||||
"ed31fbb483ee1b0a": "Actions",
|
||||
"edc70de18c6da1a6": "Install local CA",
|
||||
"ee239f3943293f87": "Sunday",
|
||||
@@ -387,6 +398,7 @@
|
||||
"f6dc1b1641600dd0": "Importing…",
|
||||
"f78265089144369a": "Plugins",
|
||||
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
|
||||
"f784b165bd3fcb0e": "Last 4 hours",
|
||||
"f85537d1fd2ef6f6": "Custom overview filters",
|
||||
"f95ea7f4c063eea7": "Disabled",
|
||||
"f9615d05d8e18595": "Clear {label} filter",
|
||||
@@ -398,6 +410,7 @@
|
||||
"fbe8778fa8b9bab5": "Initialize the local CA first",
|
||||
"fc22d1ab9ac73c6f": "Checking the plugin runtime",
|
||||
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
|
||||
"fc50d0b72bc871db": "Disable takeover",
|
||||
"fcd311fd8ad42462": "Open model list",
|
||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||
"fd77192739703811": "Bulk import",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"028c60a8a8e30a1b": "第 {page} / {total} 页",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"0525054436acdb6e": "提交信息提示词",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
"0580e0a99a6f1afc": "工件数",
|
||||
"06619f339fa0ab46": "正在准备插件运行时",
|
||||
@@ -26,11 +27,10 @@
|
||||
"0e41f8e3d59ec47b": "存储管理",
|
||||
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
||||
"0ec1e85b0c3cfa65": "调用详情",
|
||||
"0fa9ab1b5cdaca63": "授权链接",
|
||||
"100fad4a0b3ab781": "近24小时",
|
||||
"105a9082c346f958": "测试中…",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
"12ae77e6202d063e": "自定义 Headers",
|
||||
"12b86e89701b0241": "输入模型名称后按 Enter 添加",
|
||||
"133340e53175128a": "一键测试",
|
||||
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
@@ -41,6 +41,7 @@
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18165f8865eacc91": "还没有安装插件",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"19bb071f8c362695": "提示词设置",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
|
||||
"1aa65c55c6cc6163": "设备验证码",
|
||||
@@ -63,11 +64,13 @@
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
"29585d7193539200": "当前版本 {version}",
|
||||
"296da37506328d89": "关闭接管Cursor",
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2cdf7d3275016cc2": "关闭接管Cursor?",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
|
||||
@@ -76,7 +79,6 @@
|
||||
"2f5f1d6fbfb061ed": "未设置",
|
||||
"2f6416a2c424856b": "最终请求地址",
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
@@ -90,7 +92,6 @@
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"38844b135cf70dfc": "更多",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
"3a0fb74abe2460b6": "分块数",
|
||||
"3a3f595df70ec8ff": "清理存储空间",
|
||||
@@ -107,6 +108,7 @@
|
||||
"402495402ce333b1": "重新初始化插件",
|
||||
"40a08e7cf320ae07": "确定要清理详细记录吗?",
|
||||
"4125fc7ba333524c": "默认亮色",
|
||||
"4256064de039de89": "显示粒度",
|
||||
"42655ed8e4108ae2": "输入(非缓存)",
|
||||
"42a1d9e5b037c210": "字节数",
|
||||
"42aa8e01e98c0d8c": "总耗时",
|
||||
@@ -130,17 +132,19 @@
|
||||
"4d99c976beb8827e": "可用",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4eafa9e925b30bcd": "自定义",
|
||||
"4f0f45b0312ca4ba": "1小时",
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
|
||||
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
|
||||
"5319e7374f78fb38": "已接管",
|
||||
"5401344227e49e2f": "TAB 设置",
|
||||
"54644705e9c61009": "端口设置",
|
||||
"54c53e5fe791d1f3": "初始化 CA",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"550eddc3c7fefa99": "推广",
|
||||
"552a5d4baf45d878": "Commit 提交代码模型设置",
|
||||
"56432ba297009bdc": "请先初始化 CA",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"572cf45ba43634b3": "返回",
|
||||
"576d81bb0631b165": "导入",
|
||||
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
||||
"59346e82b3dd2998": "TAB 服务地址",
|
||||
@@ -155,6 +159,7 @@
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
"5fd2ec5a6e9b654c": "合计:{cost}",
|
||||
"6078a681a306930d": "缓存写入",
|
||||
"609640f72d422b57": "快捷时间范围",
|
||||
"61a4c7bac12dc125": "异常调用:{count}",
|
||||
"61c7d1f758b647d5": "调用信息",
|
||||
"621f63a5f08384ac": "缓存读取:{tokens}",
|
||||
@@ -177,7 +182,6 @@
|
||||
"6ae80538c2b2572d": "最小化窗口",
|
||||
"6d1876364ac6457d": "代理方式",
|
||||
"6e86570183c3cdd0": "当前已是最新版本",
|
||||
"6f281e1d8380a055": "收起模型",
|
||||
"7005693f4f050bce": "缓存读写 {cost}",
|
||||
"72644ec4389da2f7": "默认平铺",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
@@ -198,13 +202,14 @@
|
||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||
"80a57e03f0717f91": "未配置",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"8135034002c770ac": "查看模型",
|
||||
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"844b8cc8dff7c1d8": "默认",
|
||||
"859757ea792a73da": "30分钟",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86de7c4ee8fa7689": "同步模型",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
@@ -218,7 +223,6 @@
|
||||
"8d0c47eb9eac2d34": "调用类型",
|
||||
"8df48894086d6fbd": "原因(可选)",
|
||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||
"8ec7b052f694e3c0": "搜索模型…",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
@@ -226,11 +230,14 @@
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"92e26b27d5ea8f0e": "检查更新失败:{error}",
|
||||
"9305c0e13642cab4": "未接管",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"96f7642963ca0dbf": "开启接管Cursor",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
@@ -264,7 +271,7 @@
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
"a8036485f9227f2c": "拖动排序",
|
||||
"a8c3698b5b8c485d": "已停用",
|
||||
"a80b53f8848e6d27": "安装更新失败:{error}",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
@@ -291,6 +298,7 @@
|
||||
"b8c9b486c83b5778": "不再显示广告",
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba2e93e73037c71e": "恢复默认",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
@@ -330,14 +338,16 @@
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d7e266bdc8064193": "分组名称",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
"d8c589c455675b46": "提示词设置已保存",
|
||||
"da521d1c1cbd36af": "需要授权安装证书",
|
||||
"da7ae985487c38e6": "近1小时",
|
||||
"daede9881787abe7": "备注",
|
||||
"db340a9896306d08": "测试已取消",
|
||||
"dbaa1c565cb1f108": "15分钟",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
@@ -358,6 +368,7 @@
|
||||
"e24096c81b1a8af4": "授权被拒绝或已失败。",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e2ebc59779f012ed": "生成模型",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
"e4760c6a1df24f17": "完整请求 URL",
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
@@ -367,8 +378,8 @@
|
||||
"e77e3d58b0dcffaa": "耗时",
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
"e85373502c372776": "1分钟",
|
||||
"e8b1268c1e3610f2": "已存在",
|
||||
"ea4bf042d1bc5eb8": "模型列表",
|
||||
"ea26b760e930a7ca": "调用观测",
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
@@ -387,6 +398,7 @@
|
||||
"f6dc1b1641600dd0": "正在导入…",
|
||||
"f78265089144369a": "插件配置",
|
||||
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
|
||||
"f784b165bd3fcb0e": "近4小时",
|
||||
"f85537d1fd2ef6f6": "自定义概览筛选",
|
||||
"f95ea7f4c063eea7": "未启用",
|
||||
"f9615d05d8e18595": "清除{label}筛选",
|
||||
@@ -398,6 +410,7 @@
|
||||
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
|
||||
"fc22d1ab9ac73c6f": "正在检查插件运行时",
|
||||
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
|
||||
"fc50d0b72bc871db": "关闭接管",
|
||||
"fcd311fd8ad42462": "打开模型列表",
|
||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||
"fd77192739703811": "批量导入",
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface CursorHarnessStatus {
|
||||
configured_models: number;
|
||||
enabled_models: number;
|
||||
integration: IntegrationState;
|
||||
settings_applied: boolean;
|
||||
proxy_url: string | null;
|
||||
ca_install_command: string | null;
|
||||
}
|
||||
@@ -120,7 +121,7 @@ export interface StatisticsStorage {
|
||||
|
||||
export type StatisticsStorageScope = "details" | "all";
|
||||
|
||||
export type ProxyMode = "system" | "custom";
|
||||
export type ProxyMode = "default" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
mode: ProxyMode;
|
||||
@@ -150,6 +151,16 @@ export interface DesktopSettings {
|
||||
show_dock_icon: boolean;
|
||||
}
|
||||
|
||||
export interface CommitSettings {
|
||||
model_id: string;
|
||||
prompt: string;
|
||||
prompt_locale: Locale;
|
||||
}
|
||||
|
||||
export interface CommitSettingsView extends CommitSettings {
|
||||
default_prompt: string;
|
||||
}
|
||||
|
||||
export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported";
|
||||
export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating";
|
||||
|
||||
@@ -202,10 +213,11 @@ export interface PluginResourceView {
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0";
|
||||
type: "oauth2.0" | "oauth2.authorization-code";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
callback?: { port: number | null; path: string | null };
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
@@ -263,7 +275,7 @@ export interface PluginDescriptor {
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string;
|
||||
userCode: string | null;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
@@ -288,94 +300,9 @@ export interface PluginImportResult {
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
let cachedDisabledPluginModelIds: Set<string> = new Set();
|
||||
let cachedDisabledPluginAccountIds: Set<string> = new Set();
|
||||
let modelsLoaded = false;
|
||||
let accountsLoaded = false;
|
||||
|
||||
export function getDisabledPluginModelIds(): Set<string> {
|
||||
if (!modelsLoaded) {
|
||||
modelsLoaded = true;
|
||||
void api.disabledPluginModels().then((ids) => {
|
||||
cachedDisabledPluginModelIds = new Set(ids);
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||
}).catch(() => {});
|
||||
}
|
||||
return new Set(cachedDisabledPluginModelIds);
|
||||
}
|
||||
|
||||
export function setPluginModelEnabled(modelId: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
cachedDisabledPluginModelIds.delete(modelId);
|
||||
} else {
|
||||
cachedDisabledPluginModelIds.add(modelId);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
|
||||
}
|
||||
|
||||
export function setMultiplePluginModelsEnabled(modelIds: string[], enabled: boolean): void {
|
||||
if (enabled) {
|
||||
for (const id of modelIds) {
|
||||
cachedDisabledPluginModelIds.delete(id);
|
||||
}
|
||||
} else {
|
||||
for (const id of modelIds) {
|
||||
cachedDisabledPluginModelIds.add(id);
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
|
||||
}
|
||||
|
||||
export function getDisabledPluginAccountIds(): Set<string> {
|
||||
if (!accountsLoaded) {
|
||||
accountsLoaded = true;
|
||||
void api.disabledPluginAccounts().then((ids) => {
|
||||
cachedDisabledPluginAccountIds = new Set(ids);
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||
}).catch(() => {});
|
||||
}
|
||||
return new Set(cachedDisabledPluginAccountIds);
|
||||
}
|
||||
|
||||
export function setPluginAccountEnabled(accountId: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
cachedDisabledPluginAccountIds.delete(accountId);
|
||||
} else {
|
||||
cachedDisabledPluginAccountIds.add(accountId);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||
void api.setDisabledPluginAccounts([...cachedDisabledPluginAccountIds]).catch(() => {});
|
||||
}
|
||||
|
||||
export function updateCachedDisabledStates(models: string[], accounts: string[]): void {
|
||||
cachedDisabledPluginModelIds = new Set(models);
|
||||
cachedDisabledPluginAccountIds = new Set(accounts);
|
||||
modelsLoaded = true;
|
||||
accountsLoaded = true;
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||
}
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
const disabled = getDisabledPluginModelIds();
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) =>
|
||||
provider.configured ? provider.models.filter((m) => !disabled.has(m.id)) : []
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
@@ -527,12 +454,13 @@ export const api = {
|
||||
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[] }) => {
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; bucketMs?: number }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
params.set("start_ms", String(filter.startMs));
|
||||
params.set("end_ms", String(filter.endMs));
|
||||
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
|
||||
if (filter.bucketMs) params.set("bucket_ms", String(filter.bucketMs));
|
||||
}
|
||||
const query = params.toString();
|
||||
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
|
||||
@@ -540,10 +468,6 @@ export const api = {
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||
disabledPluginModels: () => request<string[]>("/plugins/disabled-models"),
|
||||
setDisabledPluginModels: (modelIds: string[]) => request<string[]>("/plugins/disabled-models", { method: "PUT", body: JSON.stringify({ modelIds }) }),
|
||||
disabledPluginAccounts: () => request<string[]>("/plugins/disabled-accounts"),
|
||||
setDisabledPluginAccounts: (accountIds: string[]) => request<string[]>("/plugins/disabled-accounts", { method: "PUT", body: JSON.stringify({ accountIds }) }),
|
||||
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) }),
|
||||
@@ -586,4 +510,6 @@ export const api = {
|
||||
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
desktopSettings: () => request<DesktopSettings>("/settings/desktop"),
|
||||
setDesktopSettings: (settings: DesktopSettings) => request<DesktopSettings>("/settings/desktop", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
commitSettings: (locale: Locale) => request<CommitSettingsView>("/settings/commit", { headers: { "accept-language": locale } }),
|
||||
setCommitSettings: (settings: CommitSettings) => request<CommitSettingsView>("/settings/commit", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
@@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<Update | null> {
|
||||
return check();
|
||||
export type AppUpdate = {
|
||||
version: string;
|
||||
install(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type PortableUpdateInfo = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
export async function checkForUpdate(): Promise<AppUpdate | null> {
|
||||
if (desktopPlatform() === "windows") {
|
||||
const update = await invoke<PortableUpdateInfo | null>("check_portable_update");
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: () => invoke("install_portable_update", { expectedVersion: update.version }),
|
||||
close: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const update: Update | null = await check();
|
||||
if (!update) return null;
|
||||
return {
|
||||
version: update.version,
|
||||
install: async () => {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
},
|
||||
close: () => update.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installUpdate(update: Update): Promise<void> {
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
export async function installUpdate(update: AppUpdate): Promise<void> {
|
||||
await update.install();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, updateCachedDisabledStates, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
export type AppSnapshot = {
|
||||
@@ -151,13 +151,7 @@ export const appStore = {
|
||||
},
|
||||
async refreshPlugins() {
|
||||
try {
|
||||
const [plugins, disabledModels, disabledAccounts] = await Promise.all([
|
||||
api.plugins(),
|
||||
api.disabledPluginModels(),
|
||||
api.disabledPluginAccounts(),
|
||||
]);
|
||||
updateCachedDisabledStates(disabledModels, disabledAccounts);
|
||||
update({ plugins });
|
||||
update({ plugins: await api.plugins() });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
checkForUpdate,
|
||||
hasNativeAppLifecycle,
|
||||
installUpdate,
|
||||
type AppUpdate,
|
||||
} from "../native/appLifecycle";
|
||||
|
||||
export type UpdateSnapshot = {
|
||||
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
|
||||
checking: false,
|
||||
installing: false,
|
||||
};
|
||||
let availableUpdate: Update | null = null;
|
||||
let availableUpdate: AppUpdate | null = null;
|
||||
let pendingCheck: Promise<string | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
async function replaceAvailableUpdate(next: Update | null) {
|
||||
async function replaceAvailableUpdate(next: AppUpdate | null) {
|
||||
const previous = availableUpdate;
|
||||
availableUpdate = next;
|
||||
update({ availableVersion: next?.version ?? null });
|
||||
|
||||
+105
-30
@@ -12,6 +12,7 @@
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] { border-color: var(--vscode-focusBorder); }
|
||||
&[data-disabled] { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.trigger {
|
||||
@@ -64,35 +65,6 @@
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"]:hover {
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listFrame,
|
||||
@@ -106,6 +78,101 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group,
|
||||
.groupLabel {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group {
|
||||
gap: 8px;
|
||||
padding: 7px 8px 3px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) .checkbox {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 7px 8px 3px;
|
||||
}
|
||||
|
||||
.optionRow {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px 5px 32px;
|
||||
box-sizing: border-box;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
|
||||
background: var(--vscode-checkbox-background, var(--vscode-input-background));
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.optionRow:has(input:checked) .checkbox,
|
||||
.group:has(input:checked) .checkbox,
|
||||
.checkbox[data-indeterminate] {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.checkbox[data-indeterminate]::after {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -119,7 +186,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.check { width: 1.1em; flex: 0 0 1.1em; }
|
||||
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
|
||||
.footer {
|
||||
@@ -133,7 +199,16 @@
|
||||
button {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { background: var(--vscode-list-hoverBackground); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./ModelSelect.module.scss";
|
||||
|
||||
export type ModelSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: string;
|
||||
icon?: IconProps["icon"];
|
||||
iconSrc?: string;
|
||||
};
|
||||
|
||||
type ModelSelectProps = {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| { mode: "single"; value: string; onChange: (value: string) => void }
|
||||
| { mode: "multiple"; value: string[]; onChange: (value: string[]) => void }
|
||||
);
|
||||
|
||||
type MenuItem =
|
||||
| { kind: "group"; key: string; label: string; options: ModelSelectOption[] }
|
||||
| { kind: "option"; key: string; option: ModelSelectOption };
|
||||
|
||||
function groupedItems(options: ModelSelectOption[]): MenuItem[] {
|
||||
const groups = new Map<string, ModelSelectOption[]>();
|
||||
for (const option of options) {
|
||||
const group = groups.get(option.group);
|
||||
if (group) group.push(option);
|
||||
else groups.set(option.group, [option]);
|
||||
}
|
||||
return [...groups].flatMap(([group, groupOptions]) => [
|
||||
{ kind: "group" as const, key: `group:${group}`, label: group, options: groupOptions },
|
||||
...groupOptions.map((option) => ({ kind: "option" as const, key: `option:${option.value}`, option })),
|
||||
]);
|
||||
}
|
||||
|
||||
function GroupCheckbox({ label, options, selected, onChange }: {
|
||||
label: string;
|
||||
options: ModelSelectOption[];
|
||||
selected: Set<string>;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const selectedCount = options.filter((option) => selected.has(option.value)).length;
|
||||
const checked = selectedCount === options.length;
|
||||
const indeterminate = selectedCount > 0 && !checked;
|
||||
|
||||
useEffect(() => {
|
||||
if (input.current) input.current.indeterminate = indeterminate;
|
||||
}, [indeterminate]);
|
||||
|
||||
return <label className={styles.group}>
|
||||
<input
|
||||
ref={input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className={styles.checkbox} data-indeterminate={indeterminate || undefined} aria-hidden="true">
|
||||
{checked && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
export function ModelSelect(props: ModelSelectProps) {
|
||||
const { label, options, disabled, mode } = props;
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLLabelElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
const items = useMemo(() => groupedItems(options), [options]);
|
||||
const selected = useMemo(
|
||||
() => new Set(mode === "multiple" ? props.value : [props.value]),
|
||||
[mode, props.value],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const summary = mode === "multiple"
|
||||
? props.value.length === 0 ? t("全部") : t("已选 {count} 项", { count: props.value.length })
|
||||
: options.find((option) => option.value === props.value)?.label ?? props.value;
|
||||
|
||||
const choose = (option: ModelSelectOption) => {
|
||||
if (mode === "single") {
|
||||
props.onChange(option.value);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
props.onChange(selected.has(option.value)
|
||||
? props.value.filter((item) => item !== option.value)
|
||||
: [...props.value, option.value]);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupOptions: ModelSelectOption[], checked: boolean) => {
|
||||
if (mode !== "multiple") return;
|
||||
const groupValues = new Set(groupOptions.map((option) => option.value));
|
||||
props.onChange(checked
|
||||
? [...props.value, ...groupOptions.filter((option) => !selected.has(option.value)).map((option) => option.value)]
|
||||
: props.value.filter((value) => !groupValues.has(value)));
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled) return;
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined} data-disabled={disabled || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
disabled={disabled}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{mode === "multiple" && props.value.length > 0 && <button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label={t("清除{label}筛选", { label })}
|
||||
onClick={() => props.onChange([])}
|
||||
><Icon icon={windowCloseIcon} size="1.1em" /></button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable={mode === "multiple" ? "true" : undefined}
|
||||
style={{ height: Math.min(items.length * 32, Math.max(32, position.maxHeight - (mode === "multiple" ? 45 : 0))) }}
|
||||
><VirtualList
|
||||
items={items}
|
||||
itemKey="key"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(item, index) => item.kind === "group"
|
||||
? mode === "multiple"
|
||||
? <GroupCheckbox
|
||||
label={item.label}
|
||||
options={item.options}
|
||||
selected={selected}
|
||||
onChange={(checked) => toggleGroup(item.options, checked)}
|
||||
/>
|
||||
: <div className={styles.groupLabel}>{item.label}</div>
|
||||
: <label
|
||||
ref={index === 1 ? firstOption : undefined}
|
||||
className={styles.optionRow}
|
||||
role="option"
|
||||
aria-selected={selected.has(item.option.value)}
|
||||
tabIndex={index === 1 ? 0 : -1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
choose(item.option);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.option.value)}
|
||||
tabIndex={-1}
|
||||
onChange={() => choose(item.option)}
|
||||
/>
|
||||
<span className={styles.checkbox} aria-hidden="true">
|
||||
{selected.has(item.option.value) && <Icon icon={checkIcon} size="0.875em" />}
|
||||
</span>
|
||||
<span className={styles.option}>
|
||||
{(item.option.icon || item.option.iconSrc) && <Icon icon={item.option.icon} src={item.option.iconSrc} />}
|
||||
<span>{item.option.label}</span>
|
||||
</span>
|
||||
</label>}</VirtualList></div>
|
||||
{mode === "multiple" && options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => props.onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => props.onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./MultiSelect.module.scss";
|
||||
|
||||
export type MultiSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: IconProps["icon"];
|
||||
};
|
||||
|
||||
export function MultiSelect({ label, value, options, onChange }: {
|
||||
label: string;
|
||||
value: string[];
|
||||
options: MultiSelectOption[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const selected = new Set(value);
|
||||
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
|
||||
const toggle = (option: string) => onChange(selected.has(option)
|
||||
? value.filter((item) => item !== option)
|
||||
: [...value, option]);
|
||||
|
||||
const close = () => { setOpen(false); trigger.current?.focus(); };
|
||||
const openMenu = () => {
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable="true"
|
||||
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
|
||||
><VirtualList
|
||||
items={options}
|
||||
itemKey="value"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(option, index) => <button
|
||||
ref={index === 0 ? firstOption : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={option.label}
|
||||
aria-selected={selected.has(option.value)}
|
||||
onClick={() => toggle(option.value)}
|
||||
>
|
||||
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
|
||||
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
|
||||
</button>}</VirtualList></div>
|
||||
{options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -62,55 +62,6 @@
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiCombo {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiValues {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
|
||||
> input {
|
||||
min-width: 120px;
|
||||
min-height: 24px;
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.multiCount {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.combo input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -172,23 +123,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu .multiOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { VirtualListApi } from "../virtual/virtualTypes";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import { chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
@@ -57,19 +57,44 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
|
||||
</>;
|
||||
}
|
||||
|
||||
export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) {
|
||||
export type ComboboxHandle = {
|
||||
openAll: () => void;
|
||||
};
|
||||
|
||||
type ComboboxProps = {
|
||||
value: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox({ value, options = [], placeholder, disabled, append, onChange }, ref) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filtering, setFiltering] = useState(true);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const filtered = useMemo(() => {
|
||||
if (!filtering) return options;
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
}, [options, value]);
|
||||
}, [filtering, options, value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openAll: () => {
|
||||
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
|
||||
setFiltering(false);
|
||||
setActive(0);
|
||||
setOpen(true);
|
||||
},
|
||||
}), []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
@@ -79,6 +104,9 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open, filtered.length]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeTimer.current !== null) window.clearTimeout(closeTimer.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
@@ -91,8 +119,11 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled || options.length === 0) return;
|
||||
setFiltering(true);
|
||||
setOpen(true);
|
||||
const selected = filtered.indexOf(value);
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
const matchingOptions = options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
const selected = matchingOptions.indexOf(value);
|
||||
setActive(selected >= 0 ? selected : 0);
|
||||
};
|
||||
const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); };
|
||||
@@ -102,7 +133,7 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length));
|
||||
};
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.combo}>
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => { closeTimer.current = window.setTimeout(() => setOpen(false), 100); }} onChange={(event) => { setFiltering(true); onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); }
|
||||
@@ -115,88 +146,5 @@ export function Combobox({ value, options = [], placeholder, disabled, append, o
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
|
||||
value: string[];
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
|
||||
}, [options, query]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [filtered.length, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const add = (modelId: string) => {
|
||||
const normalized = modelId.trim();
|
||||
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
|
||||
setQuery("");
|
||||
};
|
||||
const toggle = (modelId: string) => {
|
||||
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!filtered.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + filtered.length) % filtered.length);
|
||||
};
|
||||
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
|
||||
if (normalized && !exact) add(normalized);
|
||||
else if (open && filtered[active]) toggle(filtered[active]);
|
||||
}
|
||||
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
|
||||
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
|
||||
}} />
|
||||
</div>
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
|
||||
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
|
||||
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
type SwitchProps = {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children" | "onChange" | "role">;
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange, onClick, ...props }: SwitchProps) {
|
||||
return <button
|
||||
{...props}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={styles.root}
|
||||
className={[styles.root, props.className].filter(Boolean).join(" ")}
|
||||
data-checked={checked || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) onChange(!checked);
|
||||
}}
|
||||
><span /></button>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Model } from "../api";
|
||||
|
||||
export function modelProviderName(model: Pick<Model, "group_name" | "base_url">): string {
|
||||
const configuredName = model.group_name?.trim();
|
||||
if (configuredName) return configuredName;
|
||||
|
||||
const value = model.base_url.trim();
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${value}`).hostname.toLowerCase() || value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuStatusTag {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: 1px 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-descriptionForeground) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 28%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
line-height: 1.4;
|
||||
|
||||
&[data-taken] {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.menuIndicatorDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
@@ -151,7 +169,11 @@
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
// Page actions (e.g. the overview time-range bar and the floating refresh
|
||||
// button) must not act as a window drag handle; the dedicated drag layer in
|
||||
// AppHeader owns window dragging. Otherwise these controls get dragged and
|
||||
// double-click maximizes the window.
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -43,7 +43,7 @@ function loadStoredAdIds(key: string): Set<string> {
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { busy } = useAppStore();
|
||||
const { busy, cursorHarness } = useAppStore();
|
||||
const { availableVersion } = useUpdateStore();
|
||||
const { locale } = useI18n();
|
||||
const message = useMessage();
|
||||
@@ -215,6 +215,12 @@ export function AppLayout() {
|
||||
? <Icon src={item.icon} size="1.3em" />
|
||||
: <Icon icon={item.icon} size="1.3em" />}
|
||||
<span>{item.label}</span>
|
||||
{item.path === "/harness/cursor" && cursorHarness && <span
|
||||
className={styles.menuStatusTag}
|
||||
data-taken={cursorHarness.settings_applied || undefined}
|
||||
>
|
||||
{cursorHarness.settings_applied ? t("已接管") : t("未接管")}
|
||||
</span>}
|
||||
{item.path === "/settings" && availableVersion && <span className={styles.menuIndicatorDot} aria-hidden="true" />}
|
||||
</NavLink>
|
||||
</div>}
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 4%, transparent);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 9%, transparent);
|
||||
border-color: var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# deps
|
||||
/node_modules
|
||||
|
||||
# generated desktop product demo
|
||||
/public/product-demo
|
||||
|
||||
# generated content
|
||||
.source
|
||||
|
||||
# test & build
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# others
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
|
||||
# cloudflare / opennext
|
||||
/.open-next/
|
||||
/.wrangler/
|
||||
cloudflare-env.d.ts
|
||||
@@ -1,9 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,48 +0,0 @@
|
||||
# cursor-byok 文档站
|
||||
|
||||
基于 [Fumadocs](https://fumadocs.dev) 和 Next.js 的独立内容应用。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
apps/docs/
|
||||
├── app/
|
||||
│ ├── (home)/ # Hero 首页与开发者博客
|
||||
│ └── docs/ # 用户文档布局和页面
|
||||
├── components/ # MDX 组件
|
||||
├── content/
|
||||
│ ├── docs/ # 用户文档
|
||||
│ └── blog/ # 开发者博客
|
||||
└── lib/ # 内容源、站点信息与布局配置
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
先安装依赖:
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
从仓库根目录启动:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
浏览器打开 <http://localhost:3000>。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `content/docs` 中维护面向使用者的 `.mdx` 文档。
|
||||
- 在 `content/docs/meta.json` 中维护文档侧边栏标题与页面顺序。
|
||||
- 在 `content/blog` 中维护开发者文章,文件名以 `YYYY-MM-DD-` 开头用于排序和展示日期。
|
||||
- 在 `lib/shared.ts` 中维护站点名称、仓库和发布地址。
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs run check
|
||||
```
|
||||
|
||||
生产构建输出由 Next.js 管理。部署环境可通过 `NEXT_PUBLIC_SITE_URL` 设置站点公开地址;未设置时使用 `https://docs.leokun.cn`。
|
||||
@@ -1,79 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import { blogSource, formatBlogDate } from '@/lib/blog';
|
||||
import { i18n, isLanguage, type Language } from '@/lib/i18n';
|
||||
|
||||
const copy: Record<Language, { back: string; team: string }> = {
|
||||
zh: { back: '返回开发者博客', team: 'Cursor Byok 开发团队' },
|
||||
en: { back: 'Back to Developer Blog', team: 'The Cursor Byok team' },
|
||||
};
|
||||
|
||||
export default async function BlogPostPage(props: PageProps<'/[lang]/blog/[slug]'>) {
|
||||
const { lang, slug } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const page = blogSource.getPage([slug], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const prefix = `/${lang}`;
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
|
||||
<article>
|
||||
<Link
|
||||
href={`${prefix}/blog`}
|
||||
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t.back}
|
||||
</Link>
|
||||
|
||||
<header className="border-b pb-8">
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(page.path, lang)}
|
||||
</time>
|
||||
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
|
||||
<p className="mt-5 text-sm font-medium">{t.team}</p>
|
||||
</header>
|
||||
|
||||
<div className="prose mt-10 min-w-0">
|
||||
<InlineTOC items={page.data.toc} />
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: createRelativeLink(blogSource, page),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
blogSource.getPages(lang).map((page) => ({ lang, slug: page.slugs[0] })),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/[lang]/blog/[slug]'>,
|
||||
): Promise<Metadata> {
|
||||
const { lang, slug } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const page = blogSource.getPage([slug], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
import { isLanguage, type Language } from '@/lib/i18n';
|
||||
|
||||
const copy: Record<Language, { title: string; description: string; intro: string; empty: string }> = {
|
||||
zh: {
|
||||
title: '开发者博客',
|
||||
description: 'Cursor Byok 的架构决策、协议实现与开发进展。',
|
||||
intro: '记录 Cursor Byok 的架构决策、协议实现和开发进展。',
|
||||
empty: '文章正在准备中。',
|
||||
},
|
||||
en: {
|
||||
title: 'Developer Blog',
|
||||
description: 'Architecture decisions, protocol work, and development progress of Cursor Byok.',
|
||||
intro: 'Notes on architecture decisions, protocol work, and development progress of Cursor Byok.',
|
||||
empty: 'Articles are being prepared.',
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/[lang]/blog'>): Promise<Metadata> {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
return {
|
||||
title: copy[lang].title,
|
||||
description: copy[lang].description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPage(props: PageProps<'/[lang]/blog'>) {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const posts = sortBlogPages(blogSource.getPages(lang));
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
|
||||
<div className="max-w-2xl">
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight">{t.title}</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{t.intro}</p>
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div className="mt-12 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(post.path, lang)}
|
||||
</time>
|
||||
<div>
|
||||
<h2 className="font-semibold">{post.data.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-12 border-y py-8 text-fd-muted-foreground">{t.empty}</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { isLanguage, i18n } from '@/lib/i18n';
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
|
||||
const { lang } = await params;
|
||||
const base = baseOptions(isLanguage(lang) ? lang : i18n.defaultLanguage);
|
||||
|
||||
return (
|
||||
<HomeLayout
|
||||
{...base}
|
||||
nav={{ ...base.nav, transparentMode: 'always' }}
|
||||
className="home-fullbleed-nav"
|
||||
>
|
||||
{children}
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Settings2,
|
||||
Star,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { DesktopDemo } from '@/components/hero/DesktopDemo';
|
||||
import { DownloadButton } from '@/components/hero/DownloadButton';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
import { formatStars, getRepoStats } from '@/lib/github';
|
||||
import { isLanguage, type Language } from '@/lib/i18n';
|
||||
import { releaseUrl, repositoryUrl } from '@/lib/shared';
|
||||
|
||||
const copy: Record<
|
||||
Language,
|
||||
{
|
||||
pillReleased: (version: string) => string;
|
||||
pillFallback: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
readDocs: string;
|
||||
flow: [string, string, string];
|
||||
docsEyebrow: string;
|
||||
docsTitle: string;
|
||||
viewAll: string;
|
||||
docs: { icon: typeof BookOpen; title: string; description: string; href: string }[];
|
||||
blogEyebrow: string;
|
||||
blogTitle: string;
|
||||
}
|
||||
> = {
|
||||
zh: {
|
||||
pillReleased: (version) => `${version} 已发布 · 开源 · 本地运行`,
|
||||
pillFallback: '开源 · 本地运行 · 自由接入',
|
||||
title: 'Cursor 服务端的开源替代',
|
||||
subtitle:
|
||||
'在本机运行自己的模型网关,用自己的 API Key 接入 OpenAI、Anthropic 兼容服务或自定义端点,完整保留 Cursor Agent 的工具调用、Skills 和 MCP。',
|
||||
readDocs: '阅读文档',
|
||||
flow: ['Cursor 客户端', 'Cursor Byok 本地服务', '你的模型 API(OpenAI / Anthropic 兼容)'],
|
||||
docsEyebrow: 'DOCUMENTATION',
|
||||
docsTitle: '文档',
|
||||
viewAll: '查看全部',
|
||||
docs: [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: '快速开始',
|
||||
description: '完成安装、初始化和第一次模型调用。',
|
||||
href: '/docs',
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
title: '模型配置',
|
||||
description: '配置协议、服务地址、凭据和生成参数。',
|
||||
href: '/docs/model-configuration',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: '常见问题',
|
||||
description: '回答常见问题。',
|
||||
href: '/docs/faq',
|
||||
},
|
||||
],
|
||||
blogEyebrow: 'DEVELOPER BLOG',
|
||||
blogTitle: '开发者博客',
|
||||
},
|
||||
en: {
|
||||
pillReleased: (version) => `${version} released · Open source · Runs locally`,
|
||||
pillFallback: 'Open source · Runs locally · Any provider',
|
||||
title: "The open-source alternative to Cursor's backend",
|
||||
subtitle:
|
||||
'Run your own model gateway locally, connect OpenAI- and Anthropic-compatible services or custom endpoints with your own API keys, and keep Cursor Agent tool calling, Skills, and MCP intact.',
|
||||
readDocs: 'Read the docs',
|
||||
flow: ['Cursor client', 'Cursor Byok local service', 'Your model API (OpenAI / Anthropic compatible)'],
|
||||
docsEyebrow: 'DOCUMENTATION',
|
||||
docsTitle: 'Documentation',
|
||||
viewAll: 'View all',
|
||||
docs: [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'Quick Start',
|
||||
description: 'Install, initialize, and make your first model call.',
|
||||
href: '/docs',
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
title: 'Model Configuration',
|
||||
description: 'Configure protocols, server addresses, credentials, and parameters.',
|
||||
href: '/docs/model-configuration',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: 'Frequently Asked Questions',
|
||||
description: 'Answer common questions.',
|
||||
href: '/docs/faq',
|
||||
},
|
||||
],
|
||||
blogEyebrow: 'DEVELOPER BLOG',
|
||||
blogTitle: 'Developer Blog',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function HomePage(props: PageProps<'/[lang]'>) {
|
||||
const { lang } = await props.params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
const t = copy[lang];
|
||||
const prefix = `/${lang}`;
|
||||
const posts = sortBlogPages(blogSource.getPages(lang)).slice(0, 3);
|
||||
const { stars, version } = await getRepoStats();
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col">
|
||||
<section className="relative -mt-14 overflow-hidden border-b px-4 pb-16 pt-34 sm:px-6 sm:pb-24 sm:pt-42">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-fd-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-fd-border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-40 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,#000_20%,transparent_75%)]" />
|
||||
<div className="absolute left-1/2 top-[-14rem] h-[26rem] w-[44rem] -translate-x-1/2 rounded-full bg-fd-primary/10 blur-3xl" />
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<a
|
||||
href={releaseUrl}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-fd-card px-4 py-1.5 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-fd-primary opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-fd-primary" />
|
||||
</span>
|
||||
{version ? t.pillReleased(version) : t.pillFallback}
|
||||
<ArrowRight className="size-3.5" />
|
||||
</a>
|
||||
<h1 className="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">{t.title}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<DownloadButton lang={lang} />
|
||||
<Link
|
||||
href={`${prefix}/docs`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
{t.readDocs}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href={repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<GitHubIcon className="size-4" />
|
||||
GitHub
|
||||
{stars !== null ? (
|
||||
<span className="flex items-center gap-1 border-l pl-2.5 text-sm text-fd-muted-foreground">
|
||||
<Star className="size-3.5" />
|
||||
{formatStars(stars)}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopDemo lang={lang} />
|
||||
<div className="mx-auto mt-10 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 font-mono text-xs text-fd-muted-foreground sm:text-sm">
|
||||
<span>{t.flow[0]}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" />
|
||||
<span className="font-medium text-fd-foreground">{t.flow[1]}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" />
|
||||
<span>{t.flow[2]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">{t.docsEyebrow}</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.docsTitle}</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={`${prefix}/docs`}
|
||||
className="hidden items-center gap-2 text-sm font-medium sm:flex"
|
||||
>
|
||||
{t.viewAll}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||
{t.docs.map(({ icon: Icon, title, description, href }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={`${prefix}${href}`}
|
||||
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<Icon className="mb-5 size-5 text-fd-primary" />
|
||||
<h3 className="flex items-center justify-between font-semibold">
|
||||
{title}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<section className="px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">{t.blogEyebrow}</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.blogTitle}</h2>
|
||||
</div>
|
||||
<Link href={`${prefix}/blog`} className="flex items-center gap-2 text-sm font-medium">
|
||||
{t.viewAll}
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">
|
||||
{formatBlogDate(post.path, lang)}
|
||||
</time>
|
||||
<div>
|
||||
<h3 className="font-semibold">{post.data.title}</h3>
|
||||
<p className="mt-1 text-sm text-fd-muted-foreground">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from 'fumadocs-ui/layouts/docs/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import type { Metadata } from 'next';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { gitConfig } from '@/lib/shared';
|
||||
|
||||
export default async function Page(props: PageProps<'/[lang]/docs/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full} tableOfContent={{ style: 'clerk' }}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
// this allows you to link to other pages with relative file paths
|
||||
a: createRelativeLink(source, page),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<'/[lang]/docs/[[...slug]]'>,
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
images: getPageImageUrl(page).url,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { i18n, isLanguage } from '@/lib/i18n';
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) {
|
||||
const { lang } = await params;
|
||||
const language = isLanguage(lang) ? lang : i18n.defaultLanguage;
|
||||
|
||||
return (
|
||||
<DocsLayout tree={source.getPageTree(language)} {...baseOptions(language)}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { i18nProvider } from 'fumadocs-ui/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { htmlLang, i18n, isLanguage } from '@/lib/i18n';
|
||||
import { translations } from '@/lib/layout.shared';
|
||||
import '../global.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
|
||||
|
||||
export async function generateMetadata(props: LayoutProps<'/[lang]'>): Promise<Metadata> {
|
||||
const { lang } = await props.params;
|
||||
|
||||
if (lang === 'en') {
|
||||
return {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Cursor Byok Docs',
|
||||
template: '%s | Cursor Byok',
|
||||
},
|
||||
description: 'Installation, model configuration, and frequently asked questions for Cursor Byok.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Cursor Byok 文档',
|
||||
template: '%s | Cursor Byok',
|
||||
},
|
||||
description: '安装、模型配置与常见问题指南。',
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
|
||||
const { lang } = await params;
|
||||
if (!isLanguage(lang)) notFound();
|
||||
|
||||
return (
|
||||
<html lang={htmlLang[lang]} suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<RootProvider i18n={i18nProvider(translations, lang)}>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: RouteContext<'/[lang]/llms.mdx/docs/[[...slug]]'>,
|
||||
) {
|
||||
const { lang, slug } = await params;
|
||||
const page = source.getPage(slug?.slice(0, -1), lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(await getLLMText(page), {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
source.getPages(lang).map((page) => ({
|
||||
lang,
|
||||
slug: getPageMarkdownUrl(page).segments,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { getPageImageUrl, source } from '@/lib/source';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { generate as DefaultImage } from 'fumadocs-ui/og';
|
||||
import { appName } from '@/lib/shared';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(_req: Request, { params }: RouteContext<'/[lang]/og/docs/[...slug]'>) {
|
||||
const { lang, slug } = await params;
|
||||
const page = source.getPage(slug.slice(0, -1), lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return new ImageResponse(
|
||||
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.flatMap((lang) =>
|
||||
source.getPages(lang).map((page) => ({
|
||||
lang,
|
||||
slug: getPageImageUrl(page).segments,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,40 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html > body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
--removed-body-scroll-bar-size: 0px !important;
|
||||
}
|
||||
|
||||
/* 文档页目录:隐藏默认折线,改用单条直线导轨 */
|
||||
#nd-toc a[href^='#'] > svg,
|
||||
#nd-toc div.absolute:has(> svg) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#nd-toc a[href^='#'] {
|
||||
border-inline-start: 1px solid var(--color-fd-border);
|
||||
}
|
||||
|
||||
#nd-toc a[href^='#'][data-active='true'] {
|
||||
border-inline-start-color: var(--color-fd-primary);
|
||||
}
|
||||
|
||||
/* 首页导航:通铺整行、无背景无分隔线 */
|
||||
.home-fullbleed-nav {
|
||||
--fd-layout-width: 100%;
|
||||
}
|
||||
|
||||
.home-fullbleed-nav #nd-nav > * {
|
||||
border-bottom-color: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,10 +0,0 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { llms } from 'fumadocs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(llms(source).index());
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 64px auto 0;
|
||||
}
|
||||
|
||||
.halo {
|
||||
position: absolute;
|
||||
inset: -10% -14%;
|
||||
background: radial-gradient(
|
||||
50% 55% at 50% 45%,
|
||||
color-mix(in srgb, var(--color-fd-primary) 9%, transparent),
|
||||
transparent 72%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 960 / 620;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid var(--color-fd-border);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 42px 90px -34px rgb(0 0 0 / 35%),
|
||||
0 12px 32px -16px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.viewport[data-dark] {
|
||||
background: #141414;
|
||||
box-shadow:
|
||||
0 42px 90px -34px rgb(0 0 0 / 50%),
|
||||
0 12px 32px -16px rgb(0 0 0 / 42%);
|
||||
}
|
||||
|
||||
.demo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.poster {
|
||||
object-fit: cover;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.posterHidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.stage {
|
||||
margin-top: 42px;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { demoLocale, type Language } from '@/lib/i18n';
|
||||
import styles from './DesktopDemo.module.css';
|
||||
|
||||
// 小屏或触屏设备内嵌交互体验差,只展示 poster 静态图
|
||||
const embedQuery = '(min-width: 680px) and (hover: hover)';
|
||||
|
||||
const alt: Record<Language, string> = {
|
||||
zh: 'Cursor Byok 桌面端数据概览界面',
|
||||
en: 'Cursor Byok desktop overview dashboard',
|
||||
};
|
||||
|
||||
function subscribeEmbedQuery(callback: () => void) {
|
||||
const media = window.matchMedia(embedQuery);
|
||||
media.addEventListener('change', callback);
|
||||
return () => media.removeEventListener('change', callback);
|
||||
}
|
||||
|
||||
function subscribeTheme(callback: () => void) {
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
export function DesktopDemo({ lang }: { lang: Language }) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const embeddable = useSyncExternalStore(
|
||||
subscribeEmbedQuery,
|
||||
() => window.matchMedia(embedQuery).matches,
|
||||
() => true,
|
||||
);
|
||||
const dark = useSyncExternalStore(
|
||||
subscribeTheme,
|
||||
() => document.documentElement.classList.contains('dark'),
|
||||
() => true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddable) return;
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setMounted(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '240px' },
|
||||
);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [embeddable]);
|
||||
|
||||
return (
|
||||
<div ref={stageRef} className={styles.stage}>
|
||||
<div className={styles.halo} aria-hidden />
|
||||
<DemoViewport
|
||||
// key 含挂载状态:视口变小卸载 iframe 时整体重置,poster 重新显示
|
||||
key={`${dark ? 'dark' : 'light'}-${lang}-${embeddable && mounted ? 'live' : 'poster'}`}
|
||||
dark={dark}
|
||||
lang={lang}
|
||||
mounted={embeddable && mounted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoViewport({
|
||||
dark,
|
||||
lang,
|
||||
mounted,
|
||||
}: {
|
||||
dark: boolean;
|
||||
lang: Language;
|
||||
mounted: boolean;
|
||||
}) {
|
||||
const [frameLoaded, setFrameLoaded] = useState(false);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const theme = dark ? 'default-dark' : 'default-light';
|
||||
const demoUrl = `/product-demo/demo/index.html?theme=${theme}&locale=${demoLocale[lang]}`;
|
||||
const posterUrl = `/images/product-demo-${dark ? 'dark' : 'light'}-${lang}.png`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameLoaded) return;
|
||||
// iframe onLoad 只代表文档加载完成,等内部应用渲染后再揭开 poster
|
||||
const timer = setTimeout(() => setRevealed(true), 900);
|
||||
return () => clearTimeout(timer);
|
||||
}, [frameLoaded]);
|
||||
|
||||
return (
|
||||
<div className={styles.viewport} data-dark={dark || undefined}>
|
||||
{mounted ? (
|
||||
<iframe
|
||||
title={alt[lang]}
|
||||
src={demoUrl}
|
||||
className={styles.demo}
|
||||
onLoad={() => setFrameLoaded(true)}
|
||||
/>
|
||||
) : null}
|
||||
<Image
|
||||
src={posterUrl}
|
||||
alt={alt[lang]}
|
||||
fill
|
||||
priority
|
||||
unoptimized
|
||||
className={revealed ? `${styles.poster} ${styles.posterHidden}` : styles.poster}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { Language } from '@/lib/i18n';
|
||||
import { releaseUrl } from '@/lib/shared';
|
||||
|
||||
function detectPlatform(): string | null {
|
||||
const ua = navigator.userAgent;
|
||||
if (/Mac/i.test(ua)) return 'macOS';
|
||||
if (/Win/i.test(ua)) return 'Windows';
|
||||
if (/Linux|X11/i.test(ua)) return 'Linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels: Record<Language, { platform: (platform: string) => string; fallback: string }> = {
|
||||
zh: { platform: (platform) => `下载 ${platform} 版`, fallback: '下载最新版' },
|
||||
en: { platform: (platform) => `Download for ${platform}`, fallback: 'Download' },
|
||||
};
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
export function DownloadButton({ lang }: { lang: Language }) {
|
||||
// 平台只在客户端可知;SSR 用通用文案,水合后替换为平台文案
|
||||
const platform = useSyncExternalStore(subscribeNoop, detectPlatform, () => null);
|
||||
const label = labels[lang];
|
||||
|
||||
return (
|
||||
<a
|
||||
href={releaseUrl}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{platform ? label.platform(platform) : label.fallback}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
Step,
|
||||
Steps,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
export const useMDXComponents = getMDXComponents;
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Answers to common Cursor Byok questions about setup, accounts, models, and TAB completion.
|
||||
icon: Wrench
|
||||
---
|
||||
|
||||
## Q: Why does Cursor show “not signed in,” omit the model, or show `An unexpected error occurred` after an upgrade or first setup?
|
||||
|
||||
**A:** After upgrading Cursor or configuring a model for the first time, Cursor needs to establish a new connection. If you see the following error, run the complete restart sequence instead of continuing in the current conversation:
|
||||
|
||||
```text
|
||||
An unexpected error occurred. Request ID: 3871795e-2a93-4628-8308-c641047e9e43
|
||||
```
|
||||
|
||||
Complete these steps in order:
|
||||
|
||||
1. Keep Cursor Byok running.
|
||||
2. Quit Cursor completely, confirm the process has stopped, and restart it.
|
||||
3. Start a new conversation instead of continuing one opened before setup or the switch.
|
||||
4. Select your configured model from the model list.
|
||||
|
||||
The same sequence is required after a first-time Cursor upgrade or model setup. Conversations created with the current version stay synchronized with official Cursor conversations and can continue with official models.
|
||||
|
||||
## Q: Do I need to sign in to my own Cursor account when switching to the current version?
|
||||
|
||||
**A:** No. You can use your own models without a Cursor account. Signing in to any Cursor account is recommended because it provides better access to official plugins, MCP, Skills, codebase indexing, and official models. Switching Cursor accounts does not require quitting Cursor Byok.
|
||||
|
||||
## Q: Can I continue a conversation that was opened with the old version?
|
||||
|
||||
**A:** Conversations created by the old assistant cannot be continued directly in the current version; start a new conversation. Conversations created with the current version synchronize with Cursor and can switch between your models and official models.
|
||||
|
||||
## Q: Does the current version support official Cursor models?
|
||||
|
||||
**A:** Yes. Official Cursor models and your configured local models can coexist. Auto, Composer, and other official models continue to use Cursor's official service, while your configured models are routed through Cursor Byok.
|
||||
|
||||
## Q: Why does selecting Auto show a quota or account error?
|
||||
|
||||
**A:** Auto uses only official Cursor models and does not automatically use your configured models. If your account has no official quota, select your own model manually from the model list. With official quota, you can switch freely between official and configured models.
|
||||
|
||||
## Q: Can I still use Cursor plugins, MCP, Skills, and codebase indexing?
|
||||
|
||||
**A:** Yes. The current version coexists with Cursor's official service. After signing in to any Cursor account, you can continue using official plugins, MCP, Skills, Auto, and codebase indexing. Codebase indexing uses Cursor's official indexing service, so upgrade Cursor to the latest version.
|
||||
|
||||
## Q: Why is the first semantic search slow?
|
||||
|
||||
**A:** The first semantic search call automatically downloads the embedding model. Later searches use the local model. Keep the network connection available and wait for the initial download to finish.
|
||||
|
||||
## Q: Where do I configure TAB completion?
|
||||
|
||||
**A:** Go to **System Settings → TAB Settings** in Cursor Byok and choose one of these modes:
|
||||
|
||||
- **Direct connection**: connects to the official TAB service for the account signed in to Cursor.
|
||||
- **Community service**: uses the free TAB service provided by the author.
|
||||
- **Custom**: connects to a TAB completion service that you deploy separately.
|
||||
|
||||
These TAB completion capabilities all come from Cursor's official service. Custom BYOK models are generally not suitable for code completion and are not used for TAB completion. After changing this setting, restart Cursor and start a new conversation.
|
||||
|
||||
## Q: Why do Agent requests fail even though the model test passes?
|
||||
|
||||
**A:** The model test verifies basic connectivity only. Agent requests also include longer context, tool definitions, and streaming responses. Check:
|
||||
|
||||
- Whether the upstream model supports tool calling.
|
||||
- Whether the context window and maximum output tokens fit the model's limits.
|
||||
- Whether the model type, request protocol, and server address match.
|
||||
- Whether custom headers and extra parameters follow the upstream protocol.
|
||||
- The upstream status code and response body in **Call Statistics** or call details.
|
||||
|
||||
## Q: What is the local CA, and what should I do if initialization fails?
|
||||
|
||||
**A:** The local CA (certificate authority) allows Cursor Byok to inspect HTTPS requests from Cursor. Its files stay on your machine. Go to **Cursor Settings** and click **Initialize CA**. When the system asks for authorization, follow the app's terminal instructions to install and trust it, then return to the app and click **I have initialized, refresh**.
|
||||
|
||||
If the status does not update, quit and restart Cursor Byok completely, then open Cursor Settings again.
|
||||
|
||||
## Q: What should I do if the model connectivity test fails?
|
||||
|
||||
**A:** Check the error category:
|
||||
|
||||
- **Authentication error**: confirm the API key is valid and the account can access the target model.
|
||||
- **Endpoint not found**: confirm the model type, request protocol, and server address match.
|
||||
- **Model not found**: use **Fetch Models** to check the model identifiers returned by the upstream.
|
||||
- **Invalid parameters**: temporarily remove custom headers and extra parameters, then test again.
|
||||
- **Connection timeout**: check your network, system proxy, and upstream service status.
|
||||
|
||||
## Q: What should I do about a fake account generated by the old version?
|
||||
|
||||
**A:** Sign out of the fake account in Cursor, then sign in to any Cursor account, or remain signed out while using your own models. The current version does not require a fake account or a “stop service” workflow to switch between official and configured models.
|
||||
|
||||
## Q: What information should I include when reporting another issue?
|
||||
|
||||
**A:** Open a report on [GitHub Issues](https://github.com/leookun/cursor-byok/issues) with your operating system, Cursor Byok version, model type, request protocol, redacted server address, error message, and reproduction steps. Never share API keys or other credentials.
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: 常见问题
|
||||
description: 以问答形式解答 Cursor Byok 的配置、账号、模型和 TAB 补全问题。
|
||||
icon: Wrench
|
||||
---
|
||||
|
||||
## Q:首次升级或配置后,为什么 Cursor 提示“未登录”、看不到模型或出现 `An unexpected error occurred`?
|
||||
|
||||
**A:** 首次升级 Cursor 或首次配置模型后,Cursor 需要重新建立连接。遇到以下错误时,请先执行完整重启流程,而不是继续使用当前对话:
|
||||
|
||||
```text
|
||||
An unexpected error occurred. Request ID: 3871795e-2a93-4628-8308-c641047e9e43
|
||||
```
|
||||
|
||||
请按顺序完成以下操作:
|
||||
|
||||
1. 保持 Cursor Byok 运行。
|
||||
2. 完全退出 Cursor,确认进程结束后重新启动。
|
||||
3. 新建一个对话,不要继续使用配置前或切换前已经打开的对话。
|
||||
4. 在模型列表中选择你配置的模型。
|
||||
|
||||
首次升级或首次配置模型后也需要执行这套流程。新版创建的对话会与 Cursor 官方对话同步,可以和官方模型继续衔接。
|
||||
|
||||
## Q:切换到新版需要登录自己的 Cursor 账号吗?
|
||||
|
||||
**A:** 不需要。新版可以在没有 Cursor 账号的情况下使用自己的模型;不过建议在 Cursor 中登录任意账号,这样可以更好地使用官方插件、MCP、Skills、代码库索引和官方模型等功能。切换 Cursor 账号时不需要退出 Cursor Byok。
|
||||
|
||||
## Q:旧版已经打开的对话可以继续使用吗?
|
||||
|
||||
**A:** 旧助手产生的对话无法直接在新版中续接,需要新开一个对话。新版产生的对话与 Cursor 官方同步,可以在新版和官方模型之间无缝切换或继续对话。
|
||||
|
||||
## Q:新版支持 Cursor 官方模型吗?
|
||||
|
||||
**A:** 支持。新版可以让 Cursor 官方模型与自己配置的本地模型同时存在,例如 Auto、Composer 等官方模型仍由 Cursor 官方服务处理,自己配置的模型则通过 Cursor Byok 转发。
|
||||
|
||||
## Q:为什么选择 Auto 会提示额度或账号错误?
|
||||
|
||||
**A:** Auto 只会使用 Cursor 官方模型,不会自动使用你配置的本地模型。账号没有官方额度时,请在模型列表中手动选择自己的模型;有官方额度时,可以在官方模型和自己的模型之间自由切换。
|
||||
|
||||
## Q:Cursor 的插件、MCP、Skills 和代码库索引还能使用吗?
|
||||
|
||||
**A:** 可以。新版与 Cursor 官方服务并存,登录任意 Cursor 账号后可以继续使用官方插件、MCP、Skills、Auto 模型和代码库索引。代码库索引使用 Cursor 官方索引服务,需要将 Cursor 升级到最新版。
|
||||
|
||||
## Q:语意搜索第一次使用为什么比较慢?
|
||||
|
||||
**A:** 首次调用语意搜索时,应用会自动下载 Embedding Model(文本向量模型)。下载完成后,后续搜索会直接使用本地模型。模型文件较大时,请保持网络连接并耐心等待首次下载完成。
|
||||
|
||||
## Q:TAB 补全在哪里设置?
|
||||
|
||||
**A:** 在 Cursor Byok 的 **系统设置 → TAB 设置** 中选择:
|
||||
|
||||
- **直连**:连接你在 Cursor 中登录的账号对应的官方 TAB 服务。
|
||||
- **公益服务**:使用作者提供的免费 TAB 服务。
|
||||
- **自定义**:连接你自己额外部署的 TAB 补全服务。
|
||||
|
||||
这些 TAB 补全能力都来自 Cursor 官方服务。自定义的 BYOK 模型通常不适合处理代码补全,因此不会用于 TAB 补全。修改设置后,请重启 Cursor 并新建一个对话。
|
||||
|
||||
## Q:为什么模型测试通过了,Agent 请求仍然失败?
|
||||
|
||||
**A:** 模型测试只验证基础连接。Agent 请求还会包含更长的上下文、工具定义和流式响应,请检查:
|
||||
|
||||
- 上游模型是否支持工具调用。
|
||||
- 上下文窗口和最大输出 Token 是否符合模型限制。
|
||||
- 模型类型、请求协议和服务地址是否匹配。
|
||||
- 自定义 Headers 和额外参数是否符合上游协议。
|
||||
- **调用统计**或调用详情中的上游状态码和响应内容。
|
||||
|
||||
## Q:本地 CA 是什么?初始化失败怎么办?
|
||||
|
||||
**A:** 本地 CA(证书颁发机构)用于让 Cursor Byok 解析 Cursor 发出的 HTTPS 请求,相关文件只保存在本机。进入 **Cursor 配置**,点击 **初始化 CA**;系统要求授权时,按照应用提示在终端中完成安装和信任,然后返回应用点击 **我已初始化,刷新**。
|
||||
|
||||
如果状态没有更新,请完全退出并重新启动 Cursor Byok,再次打开 Cursor 配置页面。
|
||||
|
||||
## Q:模型连通性测试失败怎么办?
|
||||
|
||||
**A:** 根据测试错误检查:
|
||||
|
||||
- **认证错误**:确认 API Key 有效,并且账户有权访问目标模型。
|
||||
- **接口不存在**:确认模型类型、请求协议和服务器地址匹配。
|
||||
- **模型不存在**:使用 **获取模型** 检查上游返回的模型标识。
|
||||
- **参数错误**:暂时移除自定义 Headers 和额外参数,再重新测试。
|
||||
- **连接超时**:检查网络、系统代理和上游服务状态。
|
||||
|
||||
## Q:还在使用旧版生成的 fake 账户怎么办?
|
||||
|
||||
**A:** 在 Cursor 中退出旧版生成的 fake 账户,然后登录任意 Cursor 账号,或直接保持未登录状态使用自己的模型。新版不再需要 fake 账户,也不需要通过“停止服务”来切换官方服务和本地模型。
|
||||
|
||||
## Q:遇到其他问题时,需要提供什么信息?
|
||||
|
||||
**A:** 请在 [GitHub Issues](https://github.com/leookun/cursor-byok/issues) 提交问题,并附上操作系统、Cursor Byok 版本、模型类型、请求协议、已脱敏的服务地址、错误信息和复现步骤。请勿公开 API Key 或其他凭据。
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Install Cursor Byok and let Cursor use your own model APIs.
|
||||
icon: Rocket
|
||||
---
|
||||
|
||||
Cursor Byok is a Cursor model gateway that runs on your machine. It receives Cursor Agent requests, converts them, and forwards them to the OpenAI- or Anthropic-compatible service you configure.
|
||||
|
||||
<Callout type="warn" title="Before you start">
|
||||
Cursor Byok is an independent open-source project and is not affiliated with Cursor or its developers. The software itself is free, but model providers may charge for usage.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn" title="Restart Cursor and start a new conversation after an upgrade or first setup">
|
||||
After upgrading Cursor or configuring a model for the first time, quit Cursor completely and restart it once, then start a new conversation before selecting the model. Existing conversations do not load the new connection settings and may show “not signed in,” omit the model, or show `An unexpected error occurred`.
|
||||
</Callout>
|
||||
|
||||
## Install and configure
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Update Cursor
|
||||
|
||||
Download the latest official Cursor from [cursor.com](https://cursor.com) and install it over your existing copy, so the client stays up to date.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Download and launch Cursor Byok
|
||||
|
||||
Download the latest release for your operating system from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest), then launch cursor-byok.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Initialize and configure a model
|
||||
|
||||
Open **Cursor Configuration**, follow the prompts to initialize the local CA, then add a model. Fill in the server address, API key, and model name, and run the connectivity test until it passes.
|
||||
|
||||

|
||||
|
||||
See [Model Configuration](./model-configuration.mdx) for how to choose the type and protocol for different models.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Restart Cursor after an upgrade or first setup
|
||||
|
||||
After upgrading Cursor or configuring a model for the first time, **quit Cursor completely and restart it once**. Closing only the window or continuing in the current conversation does not load the new connection settings.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Start a new conversation and select the model
|
||||
|
||||
Once Cursor has restarted, start a new conversation and select the model you just configured from the model list (do not select Auto). Do not continue a conversation that was already open before setup.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Coexists with your official account
|
||||
|
||||
The current design goal is coexistence with the official service — there is no more "stop the service" or account juggling:
|
||||
|
||||
- Sign in to Cursor with your own account. If you previously used a fake account generated by an old version, sign out of it first, then sign in with your own.
|
||||
- If your account has official quota, there is no boundary between official models and your local models — switch and mix freely.
|
||||
- **Auto always routes to official models**: picking Auto without quota results in an error, so select your configured model instead.
|
||||
- Cursor features such as plugins and codebase indexing keep working as usual.
|
||||
|
||||
## Next steps
|
||||
|
||||
<Cards>
|
||||
<Card title="Installation" description="Download, initialize, and run for the first time." href="/docs/installation" />
|
||||
<Card title="Model Configuration" description="Choose a protocol and fill in upstream model parameters." href="/docs/model-configuration" />
|
||||
<Card title="TAB Service" description="Choose how Cursor connects to Tab completion endpoints." href="/docs/tab-service" />
|
||||
<Card title="Frequently Asked Questions" description="Answer common certificate, connection, and model configuration questions." href="/docs/faq" />
|
||||
</Cards>
|
||||
|
||||
## How data flows
|
||||
|
||||
```text
|
||||
Cursor client
|
||||
│ Agent requests and tool results
|
||||
▼
|
||||
Cursor Byok local service
|
||||
│ OpenAI- / Anthropic-compatible requests
|
||||
▼
|
||||
Your model API
|
||||
```
|
||||
|
||||
API keys, model configurations, and app settings are stored on your machine. Model requests are still sent to the upstream provider you choose.
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
title: 快速开始
|
||||
description: 安装 Cursor Byok,并让 Cursor 使用你自己的模型 API。
|
||||
icon: Rocket
|
||||
---
|
||||
|
||||
Cursor Byok 是运行在本机的 Cursor 模型网关。它接收 Cursor Agent 请求,将请求转换后发送到你配置的 OpenAI 或 Anthropic 兼容服务。
|
||||
|
||||
<Callout type="warn" title="使用前须知">
|
||||
Cursor Byok 是独立开源项目,与 Cursor 及其开发者没有关联。软件本身免费,但模型服务商可能按用量收费。
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn" title="首次升级或配置后必须重启 Cursor,并新建对话">
|
||||
首次升级 Cursor 或首次配置模型后,请完全退出并重新启动一次 Cursor,然后新建一个对话再选择模型。现有对话不会加载新的连接配置,继续使用可能提示“未登录”、无法看到模型,或出现 `An unexpected error occurred`。
|
||||
</Callout>
|
||||
|
||||
## 安装与配置
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### 更新 Cursor
|
||||
|
||||
从 [cursor.com](https://cursor.com) 下载官方最新版 Cursor,直接覆盖安装,保持客户端为最新版本。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 下载并启动 Cursor Byok
|
||||
|
||||
从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载与你的操作系统对应的最新版本,然后启动 Cursor Byok。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 初始化并配置模型
|
||||
|
||||
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试,测试通过即可。
|
||||
|
||||

|
||||
|
||||
不同模型如何选择类型与协议,见[模型配置](./model-configuration.mdx)。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 首次升级或配置后重启 Cursor
|
||||
|
||||
首次升级 Cursor 或首次配置模型后,**必须完全退出 Cursor 并重新启动一次**。仅关闭窗口或继续使用当前对话,都不会加载新的连接配置。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 新建对话并选择模型
|
||||
|
||||
Cursor 重启完成后,点击新建对话,再从模型列表中选择你刚配置的模型(不要选 Auto)。请勿继续使用配置前已经打开的对话。
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 与官方账号并存
|
||||
|
||||
新版的设计目标是与官方服务并存,不再需要“关服务”“切账号”之类的操作:
|
||||
|
||||
- 直接在 Cursor 中登录你自己的账号。如果之前用过旧版生成的 fake 账户,先退出它,再登录自己的账号。
|
||||
- 账号有官方额度时,官方模型和本地模型没有使用上的边界,可以随时切换混用。
|
||||
- **Auto 走的是官方模型**:账号没有额度时选 Auto 会报错,请手动选择你配置的模型。
|
||||
- 插件、代码库索引等 Cursor 功能都可以正常使用。
|
||||
|
||||
## 接下来
|
||||
|
||||
<Cards>
|
||||
<Card title="安装指南" description="下载、初始化和首次运行。" href="/docs/installation" />
|
||||
<Card title="模型配置" description="选择协议并填写上游模型参数。" href="/docs/model-configuration" />
|
||||
<Card title="TAB 服务" description="选择 Cursor Tab 补全接口的连接方式。" href="/docs/tab-service" />
|
||||
<Card title="常见问题" description="回答常见的证书、连接和模型配置问题。" href="/docs/faq" />
|
||||
</Cards>
|
||||
|
||||
## 数据如何流转
|
||||
|
||||
```text
|
||||
Cursor 客户端
|
||||
│ Agent 请求与工具结果
|
||||
▼
|
||||
Cursor Byok 本地服务
|
||||
│ OpenAI / Anthropic 兼容请求
|
||||
▼
|
||||
你配置的模型 API
|
||||
```
|
||||
|
||||
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商。
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Download Cursor Byok, complete local initialization, and verify it is running.
|
||||
icon: Download
|
||||
---
|
||||
|
||||
## Download the app
|
||||
|
||||
Go to the [latest release page](https://github.com/leookun/cursor-byok/releases/latest) and download the installer for macOS, Windows, or Linux.
|
||||
|
||||
<Callout type="info">
|
||||
Prefer the latest stable release. The release page lists the installers and release notes for each version.
|
||||
</Callout>
|
||||
|
||||
## First launch
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Open Cursor Settings
|
||||
|
||||
After launching the app, go to **Cursor Settings**. If the local CA has not been initialized, the page shows an initialization entry.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Initialize the local CA
|
||||
|
||||
Click **Initialize CA**. The local CA is used to inspect HTTPS requests from Cursor on your device; its files never leave your machine.
|
||||
|
||||
When the system asks for authorization, follow the instructions shown in the app to complete the trust step in your terminal, then return to the app and click **I have initialized, refresh**.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Add your first model
|
||||
|
||||
Click **Add Model**, choose the OpenAI or Anthropic type, fill in the upstream service parameters, and save. See [Model Configuration](./model-configuration.mdx) for details on each field.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Run the connectivity test
|
||||
|
||||
Click **Test** on the model. Once the test passes, the model appears in Cursor's model list.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Completely restart Cursor after an upgrade or first setup
|
||||
|
||||
After upgrading Cursor or configuring a model for the first time, **quit Cursor completely and restart it once**. Closing only the window is not enough; make sure the Cursor process has stopped before opening it again.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Start a new conversation
|
||||
|
||||
After Cursor restarts, start a new conversation and select your configured model from the model list. Do not continue a conversation opened before setup, and do not select **Auto**.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Verify the installation
|
||||
|
||||
After configuration, confirm that:
|
||||
|
||||
- The Cursor Settings page no longer shows the CA initialization prompt.
|
||||
- At least one model passes the connectivity test.
|
||||
- Cursor Byok stays running.
|
||||
- Cursor has been quit completely and restarted once.
|
||||
- You started a new conversation instead of continuing one opened before setup.
|
||||
- The configured display name shows up in Cursor's model list.
|
||||
|
||||
If any of these steps fail, head to [Frequently Asked Questions](./faq.mdx).
|
||||
|
||||
## Updates
|
||||
|
||||
Check for new versions in the app settings, or visit [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) directly. You do not need to delete existing model configurations before updating.
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
title: 安装指南
|
||||
description: 下载 Cursor Byok,完成本地初始化并验证运行状态。
|
||||
icon: Download
|
||||
---
|
||||
|
||||
## 下载应用
|
||||
|
||||
前往 [最新版本页面](https://github.com/leookun/cursor-byok/releases/latest),下载适用于 macOS、Windows 或 Linux 的安装包。
|
||||
|
||||
<Callout type="info">
|
||||
优先使用最新正式版本。发行页面会列出该版本包含的安装包和更新说明。
|
||||
</Callout>
|
||||
|
||||
## 首次启动
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### 打开 Cursor 配置
|
||||
|
||||
启动应用后进入 **Cursor 配置**。如果本地 CA 尚未初始化,页面会显示初始化入口。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 初始化本地 CA
|
||||
|
||||
点击 **初始化 CA**。本地 CA 用于在你的设备上解析 Cursor 发出的 HTTPS 请求,其文件只保存在本机。
|
||||
|
||||
系统要求授权时,按照应用显示的说明在终端中完成信任操作,然后返回应用点击 **我已初始化,刷新**。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 添加第一个模型
|
||||
|
||||
点击 **添加模型**,选择 OpenAI 或 Anthropic 类型,填写上游服务参数并保存。详细字段说明见[模型配置](./model-configuration.mdx)。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 运行连通性测试
|
||||
|
||||
点击模型的 **测试**。测试成功后,该模型即可出现在 Cursor 的模型列表中。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 首次升级或配置后完全重启 Cursor
|
||||
|
||||
首次升级 Cursor 或首次配置模型后,**必须完全退出并重新启动一次 Cursor**。仅关闭窗口不等于退出;请确认 Cursor 进程已经结束后再重新打开。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 新建对话
|
||||
|
||||
Cursor 重启后新建一个对话,再从模型列表中选择你配置的模型。不要继续使用配置前已打开的对话,也不要选择 **Auto**。
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 验证安装
|
||||
|
||||
完成配置后,请确认:
|
||||
|
||||
- Cursor 配置页面不再显示 CA 初始化提示。
|
||||
- 至少一个模型的连通性测试成功。
|
||||
- Cursor Byok 保持运行。
|
||||
- 已完全退出并重新启动一次 Cursor。
|
||||
- 已新建对话,而不是继续使用配置前打开的对话。
|
||||
- Cursor 模型列表中能看到配置的显示名称。
|
||||
|
||||
如果其中任何一步失败,请前往[常见问题](./faq.mdx)。
|
||||
|
||||
## 更新
|
||||
|
||||
在应用设置中检查新版本,或直接访问 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest)。更新前无需删除现有模型配置。
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"title": "User Guide",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"title": "使用指南",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: Model Configuration
|
||||
description: Configure the model protocol, server address, credentials, and generation parameters.
|
||||
icon: Settings2
|
||||
---
|
||||
|
||||
Each model configuration is an independent upstream channel that can use a different provider, protocol, credentials, and generation parameters.
|
||||
|
||||

|
||||
|
||||
## Choosing the type and protocol
|
||||
|
||||
Picking by model family avoids most compatibility issues:
|
||||
|
||||
| Model family | Model type | Request protocol |
|
||||
| --- | --- | --- |
|
||||
| Claude family | **Anthropic** | — |
|
||||
| GPT / OpenAI family | **OpenAI** | **Responses API** |
|
||||
| Everything else | **OpenAI** | **Chat Completions API** |
|
||||
|
||||
<Callout type="warn" title="Always use the Responses API for GPT models">
|
||||
Running GPT models through Chat Completions loses prompt caching, which makes them noticeably slower and more expensive.
|
||||
</Callout>
|
||||
|
||||
## Required fields
|
||||
|
||||
### Model type
|
||||
|
||||
Choose the upstream interface format:
|
||||
|
||||
- **OpenAI**: supports the Responses API and the Chat Completions API.
|
||||
- **Anthropic**: supports Messages API-compatible services.
|
||||
|
||||
### Request protocol
|
||||
|
||||
For the OpenAI type, also choose **Responses API** or **Chat Completions API**. This option determines the request and response format; it does not change the server address you entered.
|
||||
|
||||
### Server address
|
||||
|
||||
You can enter the provider's base URL, or choose to use a full request URL:
|
||||
|
||||
- With a base URL, Cursor Byok appends the standard endpoint path for the chosen protocol.
|
||||
- With a full request URL, Cursor Byok uses the address as-is.
|
||||
|
||||
Prefer the built-in provider presets in the UI to avoid protocol and endpoint mismatches.
|
||||
|
||||
### API key
|
||||
|
||||
Enter the access key required by the upstream service. The key is stored on your machine and used to send model requests.
|
||||
|
||||
### Model name
|
||||
|
||||
Enter the model identifier accepted by the provider's API. You can also click **Fetch Models** to load the model list returned by the API.
|
||||
|
||||
## Display information
|
||||
|
||||
- **Display name**: the name shown in Cursor's model list; it does not change the model identifier sent upstream.
|
||||
- **Notes**: shown in Cursor's model description.
|
||||
|
||||
## Optional parameters
|
||||
|
||||
Fill in the following fields based on the model's capabilities; when left empty, the app or upstream defaults are used:
|
||||
|
||||
- Context window tokens
|
||||
- Max output tokens
|
||||
- Reasoning or thinking effort
|
||||
- Custom headers
|
||||
- Extra OpenAI or Anthropic parameters
|
||||
|
||||
<Callout type="warn" title="Extra parameter format">
|
||||
Custom headers and extra parameters must be JSON objects. Extra parameters directly affect upstream requests, so only add fields the provider explicitly supports.
|
||||
</Callout>
|
||||
|
||||
## Test the configuration
|
||||
|
||||
Run **Test** after saving. The result shows time to first token, generation speed, total duration, and the model output, which helps you confirm:
|
||||
|
||||
1. The address and protocol match.
|
||||
2. The API key is valid.
|
||||
3. The model identifier exists and is accessible.
|
||||
4. The upstream returns streamed content correctly.
|
||||
|
||||
Once the test passes, switch to Cursor and start using the model.
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: 模型配置
|
||||
description: 配置模型协议、服务地址、凭据与生成参数。
|
||||
icon: Settings2
|
||||
---
|
||||
|
||||
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
|
||||
|
||||

|
||||
|
||||
## 如何选择类型与协议
|
||||
|
||||
按上游模型系列选择,可以避免绝大多数兼容性问题:
|
||||
|
||||
| 模型系列 | 模型类型 | 请求协议 |
|
||||
| --- | --- | --- |
|
||||
| Claude 系列 | **Anthropic** | — |
|
||||
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
|
||||
| 其他所有模型 | **OpenAI** | **Chat Completions API** |
|
||||
|
||||
<Callout type="warn" title="GPT 系列务必用 Responses API">
|
||||
GPT 系列走 Chat Completions 会丢失提示词缓存,速度和费用都会明显变差。
|
||||
</Callout>
|
||||
|
||||
## 必填字段
|
||||
|
||||
### 模型类型
|
||||
|
||||
选择上游接口格式:
|
||||
|
||||
- **OpenAI**:支持 Responses API 和 Chat Completions API。
|
||||
- **Anthropic**:支持 Messages API 兼容服务。
|
||||
|
||||
### 请求协议
|
||||
|
||||
OpenAI 类型需要继续选择 **Responses API** 或 **Chat Completions API**。该选项决定请求和响应格式,不会单独改变你填写的服务地址。
|
||||
|
||||
### 服务器地址
|
||||
|
||||
可以填写服务商的基础地址,也可以选择使用完整请求 URL:
|
||||
|
||||
- 使用基础地址时,Cursor Byok 会根据协议追加标准端点路径。
|
||||
- 使用完整请求 URL 时,Cursor Byok 会原样使用该地址。
|
||||
|
||||
优先使用界面中的常用服务商预设,减少协议与端点不匹配的情况。
|
||||
|
||||
### API Key
|
||||
|
||||
填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
|
||||
|
||||
### 模型名称
|
||||
|
||||
填写服务商接口接受的模型标识。你也可以点击 **获取模型** 读取接口返回的模型列表。
|
||||
|
||||
## 展示信息
|
||||
|
||||
- **显示名称**:Cursor 模型列表中看到的名称,不会改变发送给上游的模型标识。
|
||||
- **备注**:显示在 Cursor 的模型说明中。
|
||||
|
||||
## 可选参数
|
||||
|
||||
按模型能力填写以下字段;留空时使用应用或上游的默认值:
|
||||
|
||||
- 上下文窗口 Token
|
||||
- 最大输出 Token
|
||||
- 推理强度或思考强度
|
||||
- 自定义 Headers
|
||||
- OpenAI 或 Anthropic 额外参数
|
||||
|
||||
<Callout type="warn" title="额外参数格式">
|
||||
自定义 Headers 和额外参数必须是 JSON 对象。额外参数会直接影响上游请求,只添加服务商明确支持的字段。
|
||||
</Callout>
|
||||
|
||||
## 测试配置
|
||||
|
||||
保存后运行 **测试**。结果会显示首字延迟、生成速度、总耗时和模型输出,便于确认:
|
||||
|
||||
1. 地址和协议是否匹配。
|
||||
2. API Key 是否有效。
|
||||
3. 模型标识是否存在且可访问。
|
||||
4. 上游是否能正常返回流式内容。
|
||||
|
||||
测试通过后再前往 Cursor 使用该模型。
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
title: Plugin Development
|
||||
description: Build stateless TypeScript plugins that execute providers, enumerate models, and manage credential resources with OAuth sign-in.
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
Plugins implement three capability interfaces defined by the core: **Provider** (execute one LLM call), **Model** (enumerate available models), and **Resource** (credential resources such as accounts). Plugins hold no persistent state — resources and model catalogs are stored by the core, and every call receives the data it needs as arguments.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # static identity, entry, icon, HTTPS host allowlist
|
||||
│ ├── main.ts # defineProviderPlugin composing providers and resources
|
||||
│ └── assets/icon.svg # local icon, 1 MiB max
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # resource records persisted by the core (0600)
|
||||
└── models-<provider>.json # model catalogs persisted by the core
|
||||
```
|
||||
|
||||
Built-in plugin sources live at `server/plugins/build-in/` (such as `codex-auth`); they are bundled into the binary and pre-installed into `plugins/installed/` keyed by version. Debug builds load the source directory first so edits take effect immediately.
|
||||
|
||||
## Static manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. `version` is required; the plugin is ignored when the app version is older than `minAppVersion`. Built-in plugins are pre-installed into `plugins/installed/` keyed by `version`: startup writes nothing when the version matches and resyncs the whole directory (pruning stale files) when it changes.
|
||||
|
||||
## Entry and capabilities
|
||||
|
||||
The host injects `cursor-byok:plugin`, `cursor-byok:provider`, `cursor-byok:model`, `cursor-byok:resource`, and the protocol helper `cursor-byok:protocol/openai-responses`.
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// Discover upstream models with the first ready resource; the return
|
||||
// value replaces the core-side catalog.
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* updated quota */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **The core owns**: resource persistence and dedupe (upsert by `draft.key`), resource list UI, the OAuth poll loop (interval, slow-down, expiry), model catalog storage, per-call resource selection (currently the first ready resource; cooling expires automatically), call records and statistics.
|
||||
- **The plugin owns**: authentication HTTP transitions (`begin`/`poll`), credential parsing (`import.parse`), resource presentation (`present`), quota refresh (`refresh`), and protocol adaptation with streaming execution (`invoke`).
|
||||
|
||||
`invoke` receives the full `LlmRequest` (instructions, message history, tools, reasoning config), parses the upstream SSE while emitting normalized events through `output.emit()` (text/thinking boundaries, incremental tool arguments, replay state, usage, finish reason), and finally returns `completed` or a typed error. The `patch` carried by `resource-error` is applied atomically to the selected resource and is the basis for future load-balanced retries.
|
||||
|
||||
Stable model IDs take the form `plugin:<plugin-id>/<provider-id>/<model-id>`; every enumerated model enters the Cursor catalog independently.
|
||||
|
||||
## Host context
|
||||
|
||||
- `context.network.fetch(url, init)`: one-shot HTTPS request, strictly allowlisted.
|
||||
- `context.network.stream(url, init)`: streaming response iterated line by line (for SSE).
|
||||
- `context.signal`: fires when the host cancels the call.
|
||||
|
||||
## Sandbox
|
||||
|
||||
The Deno process can only read its own plugin directory and the host SDK directory. Remote modules, npm packages, direct network access, environment variables, subprocesses, and file writes are all disabled. The worker multiplexes requests by request ID; a crash fails the current request and restarts on demand.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
title: 插件开发
|
||||
description: 用无状态 TypeScript 插件实现 Provider 执行、模型枚举、资源接入与 OAuth 登录。
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
插件实现核心定义的三种能力接口:**Provider**(执行一次 LLM 调用)、**Model**(枚举可用模型)、**Resource**(账号等凭证资源)。插件不持有任何持久状态——资源与模型目录由核心存储,每次调用所需数据都通过参数传入。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # 静态身份、入口、图标和 HTTPS 主机白名单
|
||||
│ ├── main.ts # defineProviderPlugin 组合 providers 与 resources
|
||||
│ └── assets/icon.svg # 本地图标,最大 1 MiB
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # 核心持久化的资源记录(0600)
|
||||
└── models-<provider>.json # 核心持久化的模型目录
|
||||
```
|
||||
|
||||
内置插件源码位于 `server/plugins/build-in/`(如 `codex-auth`),随二进制打包并按版本预装进 `plugins/installed/`;Debug 构建下源码目录优先加载,便于热改。
|
||||
|
||||
## 静态清单
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"version": "0.1.0",
|
||||
"author": "@example",
|
||||
"minAppVersion": "0.1.0",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。`version` 必填;应用版本低于 `minAppVersion` 时插件会被忽略。内置插件按 `version` 预装进 `plugins/installed/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。
|
||||
|
||||
## 入口与能力
|
||||
|
||||
宿主注入 `cursor-byok:plugin`、`cursor-byok:provider`、`cursor-byok:model`、`cursor-byok:resource` 与协议帮助库 `cursor-byok:protocol/openai-responses`。
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// 用首个可用资源发现上游模型;返回值整体替换核心目录。
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* 更新额度 */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## 职责边界
|
||||
|
||||
- **核心负责**:资源持久化与去重(按 `draft.key` upsert)、资源列表 UI、OAuth 轮询循环(间隔、slow-down、超时)、模型目录存储、每次调用的资源选择(当前取首个可用,冷却到期自动恢复)、调用记录与统计。
|
||||
- **插件负责**:认证 HTTP 转移(`begin`/`poll`)、凭证解析(`import.parse`)、资源展示投影(`present`)、额度刷新(`refresh`)、协议适配与流式执行(`invoke`)。
|
||||
|
||||
`invoke` 接收完整 `LlmRequest`(指令、消息历史、工具、思考配置),边解析上游 SSE 边 `output.emit()` 标准化事件(文本/思考边界、工具参数增量、回放状态、用量、结束原因),最后返回 `completed` 或类型化错误。`resource-error` 携带的 `patch` 会被核心原子应用到选中的资源,是未来负载均衡换资源重试的依据。
|
||||
|
||||
稳定模型 ID 为 `plugin:<plugin-id>/<provider-id>/<model-id>`,每个枚举出的模型都独立进入 Cursor 模型目录。
|
||||
|
||||
## 宿主上下文
|
||||
|
||||
- `context.network.fetch(url, init)`:一次性 HTTPS 请求,严格执行清单白名单。
|
||||
- `context.network.stream(url, init)`:流式响应,按行异步迭代(用于 SSE)。
|
||||
- `context.signal`:宿主取消本次调用时触发。
|
||||
|
||||
## 沙箱
|
||||
|
||||
Deno 进程只能读取自身插件目录和宿主 SDK 目录。远程模块、npm 包、直接网络、环境变量、子进程和文件写入均被禁用。Worker 按请求 ID 多路复用,崩溃时当前请求失败并按需重启。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: TAB Service
|
||||
description: Choose how Cursor connects to Tab completion endpoints.
|
||||
icon: Zap
|
||||
---
|
||||
|
||||
Cursor's Tab completion does not go through model channels — it is handled by a separate Tab service. Cursor Byok offers three connection modes under **System settings → TAB settings**.
|
||||
|
||||

|
||||
|
||||
## The three modes
|
||||
|
||||
### Use public service (default)
|
||||
|
||||
Uses the public Tab service hosted by the project author. Works out of the box with no configuration.
|
||||
|
||||
### Direct
|
||||
|
||||
Connects directly to the official Tab service of the account signed in to your Cursor client. Suitable if your account has official quota.
|
||||
|
||||
### Custom
|
||||
|
||||
Deploy your own [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server), then enter your service URL in **TAB service address**. The original endpoint path is appended to that address.
|
||||
|
||||
<Callout type="info">
|
||||
After changing TAB settings, restart Cursor and start a new conversation to make sure the connection mode takes effect.
|
||||
</Callout>
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: TAB 服务
|
||||
description: 选择 Cursor Tab 补全接口的连接方式。
|
||||
icon: Zap
|
||||
---
|
||||
|
||||
Cursor 的 Tab 补全不走模型通道,而是由单独的 Tab 服务处理。Cursor Byok 在 **系统设置 → TAB 设置** 中提供三种连接方式。
|
||||
|
||||

|
||||
|
||||
## 三种模式
|
||||
|
||||
### 使用公益服务(默认)
|
||||
|
||||
使用由项目作者部署的公共 Tab 服务,开箱即用,无需任何配置。
|
||||
|
||||
### 直连
|
||||
|
||||
直接使用你在 Cursor 客户端中登录账号的官方 Tab 服务。适合账号本身有官方额度的用户。
|
||||
|
||||
### 自定义
|
||||
|
||||
自行部署 [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后在 **TAB 服务地址** 中填入你的服务地址。原接口路径会追加到该地址之后。
|
||||
|
||||
<Callout type="info">
|
||||
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保连接方式生效。
|
||||
</Callout>
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
'.source/**',
|
||||
'public/product-demo/**',
|
||||
'.open-next/**',
|
||||
'.wrangler/**',
|
||||
'cloudflare-env.d.ts',
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { defineCollections } from 'fumadocs-mdx/macro';
|
||||
import { htmlLang, i18n, type Language } from './i18n';
|
||||
|
||||
const blog = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/blog',
|
||||
schema: pageSchema,
|
||||
});
|
||||
|
||||
export const blogSource = loader({
|
||||
baseUrl: '/blog',
|
||||
source: blog.toFumadocsSource(),
|
||||
i18n,
|
||||
});
|
||||
|
||||
export function getBlogDate(path: string): Date {
|
||||
const fileName = path.split('/').at(-1) ?? '';
|
||||
const match = /^(\d{4}-\d{2}-\d{2})/.exec(fileName);
|
||||
return match ? new Date(`${match[1]}T00:00:00Z`) : new Date(0);
|
||||
}
|
||||
|
||||
export function formatBlogDate(path: string, lang: Language = i18n.defaultLanguage): string {
|
||||
return new Intl.DateTimeFormat(htmlLang[lang], {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(getBlogDate(path));
|
||||
}
|
||||
|
||||
export function sortBlogPages<T extends { path: string }>(pages: T[]): T[] {
|
||||
return [...pages].sort((left, right) => {
|
||||
return getBlogDate(right.path).getTime() - getBlogDate(left.path).getTime();
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { cn } from 'cnfast';
|
||||
@@ -1,40 +0,0 @@
|
||||
import { gitConfig } from './shared';
|
||||
|
||||
export type RepoStats = {
|
||||
stars: number | null;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
// 构建期取一次并静态化;数据随每次部署更新,运行时无需 ISR 缓存设施
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
cache: 'force-cache',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoStats(): Promise<RepoStats> {
|
||||
const base = `https://api.github.com/repos/${gitConfig.user}/${gitConfig.repo}`;
|
||||
const [repo, release] = await Promise.all([
|
||||
fetchJson(base),
|
||||
fetchJson(`${base}/releases/latest`),
|
||||
]);
|
||||
|
||||
return {
|
||||
stars: typeof repo?.stargazers_count === 'number' ? repo.stargazers_count : null,
|
||||
version: typeof release?.tag_name === 'string' ? release.tag_name : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStars(stars: number): string {
|
||||
return new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(stars);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineI18n } from 'fumadocs-core/i18n';
|
||||
|
||||
export const i18n = defineI18n({
|
||||
defaultLanguage: 'zh',
|
||||
languages: ['zh', 'en'],
|
||||
// 所有语言显式带前缀;未带前缀的访问由中间件按浏览器 Accept-Language 协商后重定向
|
||||
hideLocale: 'never',
|
||||
});
|
||||
|
||||
export type Language = (typeof i18n)['languages'][number];
|
||||
|
||||
export function isLanguage(value: string): value is Language {
|
||||
return i18n.languages.some((lang) => lang === value);
|
||||
}
|
||||
|
||||
/** HTML `lang` attribute values. */
|
||||
export const htmlLang: Record<Language, string> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en',
|
||||
};
|
||||
|
||||
/** Locale ids understood by the desktop app / demo. */
|
||||
export const demoLocale: Record<Language, string> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en-US',
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user