Compare commits

..
Author SHA1 Message Date
leookun 21e670e1d5 chore: start documentation work 2026-08-25 00:12:34 +08:00
227 changed files with 4931 additions and 24159 deletions
-30
View File
@@ -213,36 +213,6 @@ jobs:
--output legacy-update/update.json \ --output legacy-update/update.json \
--notes "Cursor BYOK v${VERSION}" --notes "Cursor BYOK v${VERSION}"
- name: Normalize Tauri updater download URLs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
release_id=$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
)
test -n "${release_id}"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
test -n "${asset_id}"
gh api \
-H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
> tauri-update/latest.json
node scripts/release/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
--version "${VERSION}"
- name: Upload normalized Tauri updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
- name: Upload legacy updater assets - name: Upload legacy updater assets
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Generated
+3 -121
View File
@@ -926,7 +926,7 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types",
"libc", "libc",
] ]
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]] [[package]]
name = "cursor-byok-desktop" name = "cursor-byok-desktop"
version = "0.1.4" version = "0.1.0-beta.10"
dependencies = [ dependencies = [
"axum", "axum",
"cursor-server", "cursor-server",
@@ -1183,7 +1183,6 @@ dependencies = [
"semble-core", "semble-core",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"sha1 0.10.7", "sha1 0.10.7",
"sha2", "sha2",
"similar", "similar",
@@ -1905,15 +1904,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -1921,7 +1911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared 0.3.1", "foreign-types-shared",
] ]
[[package]] [[package]]
@@ -1935,12 +1925,6 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -2742,22 +2726,6 @@ dependencies = [
"webpki-roots 1.0.9", "webpki-roots 1.0.9",
] ]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-tungstenite" name = "hyper-tungstenite"
version = "0.30.0" version = "0.30.0"
@@ -3671,23 +3639,6 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -4086,49 +4037,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -5128,11 +5042,9 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -5143,7 +5055,6 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
@@ -5750,19 +5661,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -7012,16 +6910,6 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -7599,12 +7487,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"
+1 -8
View File
@@ -1,20 +1,16 @@
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key 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: check:
cargo fmt --all -- --check cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets cargo test --workspace --all-targets
npm --prefix apps/desktop run check npm --prefix apps/desktop run check
npm --prefix apps/docs run check
dev-web: dev-web:
npm --prefix apps/desktop run dev:web npm --prefix apps/desktop run dev:web
dev-docs:
npm --prefix apps/docs run dev
dev-server: dev-server:
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
@@ -24,9 +20,6 @@ dev-desktop:
build-web: build-web:
npm --prefix apps/desktop run build npm --prefix apps/desktop run build
build-docs:
npm --prefix apps/docs run build
build-server: build-server:
cargo build --release --package cursor-server --bin cursor-server cargo build --release --package cursor-server --bin cursor-server
-23
View File
@@ -1,23 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141414" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: #141414;
}
</style>
<title>Cursor BYOK 产品演示</title>
</head>
<body>
<div id="root"></div>
<script src="/src/demo/index.tsx" type="module"></script>
</body>
</html>
+32 -17
View File
@@ -1,12 +1,12 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.4", "version": "0.1.0-beta.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.4", "version": "0.1.0-beta.10",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.8.0",
@@ -18,14 +18,15 @@
"@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11", "keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7", "keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0", "monaco-editor": "0.56.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0" "zrender": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -36,7 +37,6 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6", "code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
@@ -425,6 +425,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.142.0", "version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
@@ -1287,13 +1293,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1809,6 +1808,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2978,6 +2989,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
@@ -3164,12 +3185,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+3 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.4", "version": "0.1.0-beta.10",
"description": "Cursor BYOK desktop management application", "description": "Cursor BYOK desktop management application",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,7 +10,6 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "STATIC_I18N_SCAN=true vite build", "i18n:scan": "STATIC_I18N_SCAN=true vite build",
"build": "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", "check": "npm run typecheck && npm run typecheck:node && npm run build",
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"", "dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
"serve": "vite preview", "serve": "vite preview",
@@ -29,14 +28,15 @@
"@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11", "keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7", "keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0", "monaco-editor": "0.56.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
"zrender": "^6.1.0" "zrender": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -47,7 +47,6 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6", "code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cursor-byok-desktop" name = "cursor-byok-desktop"
version = "0.1.4" version = "0.1.0-beta.10"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -16,7 +16,6 @@
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-close", "core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command", "allow-open-terminal-with-command",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"autostart:default", "autostart:default",
+14 -52
View File
@@ -15,7 +15,7 @@ use axum::{
}; };
use tauri::{ use tauri::{
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl, async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
WebviewWindow, WebviewWindowBuilder, WebviewWindowBuilder,
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -30,7 +30,6 @@ use crate::frontend;
use crate::tray; use crate::tray;
pub(crate) const MAIN_WINDOW_LABEL: &str = "main"; pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const AUTOSTART_ARG: &str = "--autostart";
struct DesktopRuntime { struct DesktopRuntime {
shutdown: CancellationToken, shutdown: CancellationToken,
@@ -53,30 +52,12 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> {
.spawn()?; .spawn()?;
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")] #[cfg(not(any(target_os = "macos", target_os = "windows")))]
{ {
const TERMINALS: &[(&str, &[&str])] = &[ let _ = command;
("x-terminal-emulator", &["-e"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["--execute"]),
("alacritty", &["-e"]),
("kitty", &[]),
];
let script = format!("{command}; exec bash");
for (terminal, separator) in TERMINALS {
let mut process = Command::new(terminal);
process.args(*separator);
process.arg("bash").arg("-c").arg(&script);
match process.spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
}
Err(tauri::Error::from(std::io::Error::new( Err(tauri::Error::from(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::Unsupported,
"no supported terminal emulator found", "terminal guidance is unsupported on this platform",
))) )))
} }
} }
@@ -115,10 +96,7 @@ fn desktop_api_router(app: AppHandle) -> Router {
.layer(Extension(app)) .layer(Extension(app))
} }
fn create_main_window( fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
app: &AppHandle,
address: std::net::SocketAddr,
) -> tauri::Result<WebviewWindow> {
let url = format!("http://{address}/__byok-api__/") let url = format!("http://{address}/__byok-api__/")
.parse() .parse()
.expect("local frontend URL"); .expect("local frontend URL");
@@ -130,20 +108,18 @@ fn create_main_window(
.background_color(Color(20, 20, 20, 255)) .background_color(Color(20, 20, 20, 255))
.decorations(cfg!(target_os = "macos")) .decorations(cfg!(target_os = "macos"))
.shadow(true) .shadow(true)
.resizable(true) .resizable(true);
.visible(false);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let builder = builder let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay) .title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true); .hidden_title(true);
builder.build() builder.build()?;
Ok(())
} }
pub fn run() { pub fn run() {
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
@@ -154,19 +130,17 @@ pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_terminal_with_command]) .invoke_handler(tauri::generate_handler![open_terminal_with_command])
.plugin(tauri_plugin_single_instance::init(|app, args, _| { .plugin(tauri_plugin_single_instance::init(|app, _, _| {
if !args.iter().any(|arg| arg == AUTOSTART_ARG) { tray::show_main_window(app);
tray::show_main_window(app);
}
})) }))
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| { .setup(|app| {
app.handle().plugin(tauri_plugin_autostart::init( app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent, tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec![AUTOSTART_ARG]), None,
))?; ))?;
let config = Config::desktop()?; let config = Config::desktop()?;
#[cfg(dev)] #[cfg(dev)]
@@ -186,12 +160,6 @@ pub fn run() {
let listener = tauri::async_runtime::block_on(server.bind())?; let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?; let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?; tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let desktop_settings =
tauri::async_runtime::block_on(server.store().desktop_settings())
.unwrap_or_default();
#[cfg(target_os = "macos")]
app.handle()
.set_dock_visibility(desktop_settings.show_dock_icon)?;
let shutdown = CancellationToken::new(); let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone(); let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
@@ -208,13 +176,7 @@ pub fn run() {
server: Mutex::new(Some(task)), server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false), exiting: AtomicBool::new(false),
}); });
let window = create_main_window(app.handle(), address)?; create_main_window(app.handle(), address)?;
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; keeping the main window hidden");
} else {
window.show()?;
window.set_focus()?;
}
tray::create(app)?; tray::create(app)?;
Ok(()) Ok(())
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK", "productName": "Cursor BYOK",
"version": "0.1.4", "version": "0.1.0-beta.10",
"identifier": "dev.cursorbyok.desktop", "identifier": "dev.cursorbyok.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+2
View File
@@ -8,6 +8,7 @@ import { CallsPage } from "./pages/CallsPage";
import { CallDetailsPage } from "./pages/CallDetailsPage"; import { CallDetailsPage } from "./pages/CallDetailsPage";
import { CursorSettingsPage } from "./pages/CursorSettingsPage"; import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { useAppStore } from "./store/appStore"; import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore"; import { updateStore } from "./store/updateStore";
@@ -21,6 +22,7 @@ export function App() {
<Route element={<AppFrame />}> <Route element={<AppFrame />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="calls" element={<CallsPage />} /> <Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} /> <Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
+60 -87
View File
@@ -1,87 +1,60 @@
import type { AdRuntime } from "./components/ads/types"; import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime"; import type { Locale } from "./i18n/runtime";
export type ModelType = "openai" | "anthropic"; export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
export interface Provider {
provider_id: number;
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
has_api_key: boolean;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
created_at_ms: number;
updated_at_ms: number;
}
export interface ProviderInput {
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
}
export interface Model { export interface Model {
model_hash: string; model_hash: string;
sort_order: number; provider_id: number;
display_name: string;
type: ModelType;
base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string; model_id: string;
reasoning_effort: string | null; display_name: string;
openai_endpoint: string; endpoint_type: ProviderType;
openai_extra_params_enabled: boolean; request_url: string;
openai_extra_params: Record<string, unknown>; enabled: boolean;
custom_headers_enabled: boolean; sort_order: number;
custom_headers: Record<string, string>;
anthropic_extra_params_enabled: boolean;
anthropic_extra_params: Record<string, unknown>;
context_window_tokens: number | null; context_window_tokens: number | null;
max_completion_tokens: number | null; max_output_tokens: number | null;
anthropic_max_tokens: number | null; reasoning_enabled: boolean;
anthropic_thinking_effort: string | null; reasoning_effort: string | null;
thinking_budget_tokens: number | null; supports_image_generation: boolean;
created_at_ms: number; created_at_ms: number;
updated_at_ms: number; updated_at_ms: number;
} }
export interface ModelInput { export interface ModelInput {
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number; sort_order: number;
display_name: string;
type: ModelType;
base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string;
reasoning_effort: string | null;
openai_endpoint: string;
openai_extra_params_enabled: boolean;
openai_extra_params: Record<string, unknown>;
custom_headers_enabled: boolean;
custom_headers: Record<string, string>;
anthropic_extra_params_enabled: boolean;
anthropic_extra_params: Record<string, unknown>;
context_window_tokens: number | null; context_window_tokens: number | null;
max_completion_tokens: number | null; max_output_tokens: number | null;
anthropic_max_tokens: number | null; reasoning_enabled: boolean;
anthropic_thinking_effort: string | null; reasoning_effort: string | null;
thinking_budget_tokens: number | null; supports_image_generation: boolean;
}
export interface ModelDiscoveryInput {
type: ModelType;
base_url: string;
api_key: string;
custom_headers_enabled: boolean;
custom_headers: Record<string, string>;
}
export interface LegacyModelImportPreviewItem {
model_hash: string;
display_name: string;
model_id: string;
type: ModelType;
existing: boolean;
}
export interface LegacyModelImportPreview {
source: string;
total: number;
new_models: number;
existing_models: number;
models: LegacyModelImportPreviewItem[];
}
export interface LegacyModelImportResult {
imported: number;
skipped: number;
total: number;
} }
export interface ModelConnectivityResult { export interface ModelConnectivityResult {
@@ -93,7 +66,7 @@ export interface ModelConnectivityResult {
output: string; output: string;
} }
export type CaState = "missing" | "untrusted" | "ready" | "invalid"; export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
export type IntegrationState = "disabled" | "enabled" | "degraded"; export type IntegrationState = "disabled" | "enabled" | "degraded";
export interface CursorHarnessStatus { export interface CursorHarnessStatus {
platform: string; platform: string;
@@ -141,11 +114,6 @@ export interface TabSettings {
address: string; address: string;
} }
export interface DesktopSettings {
silent_start: boolean;
show_dock_icon: boolean;
}
export interface OverviewMetrics { export interface OverviewMetrics {
llm_calls: number; llm_calls: number;
successful_calls: number; successful_calls: number;
@@ -174,6 +142,10 @@ export interface Overview {
token_usage_series: OverviewTokenUsageBucket[]; token_usage_series: OverviewTokenUsageBucket[];
} }
export type ProviderSelection =
| { kind: "existing"; provider_id: number }
| { kind: "new"; input: ProviderInput };
export interface LlmCall { export interface LlmCall {
call_kind: "provider_llm" | "cursor_official"; call_kind: "provider_llm" | "cursor_official";
route: "local_byok" | "cursor_official"; route: "local_byok" | "cursor_official";
@@ -284,26 +256,29 @@ export const api = {
}); });
}, },
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }), dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
providers: () => request<Provider[]>("/providers"),
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
models: () => request<Model[]>("/models"), models: () => request<Model[]>("/models"),
createModels: (models: ModelInput[]) => request<Model[]>("/models", { method: "POST", body: JSON.stringify({ models }) }),
reorderModels: (modelHashes: string[]) => request<Model[]>("/models/order", { method: "PUT", body: JSON.stringify({ model_hashes: modelHashes }) }),
discoverModels: (input: ModelDiscoveryInput) => request<{ models: string[] }>("/models/discover", { method: "POST", body: JSON.stringify(input) }),
previewV0049Models: () => request<LegacyModelImportPreview>("/models/import-v0049"),
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }), updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }), deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }), testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }), overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter) { if (filter) {
params.set("start_ms", String(filter.startMs)); params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs)); params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes)); if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
} }
const query = params.toString(); const query = params.size ? `?${params}` : "";
return request<Overview>(`/overview${query ? `?${query}` : ""}`); return request<Overview>(`/overview${query}`);
}, },
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"), cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }), initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
openCursorCaInstallTerminal: async (command: string) => { openCursorCaInstallTerminal: async (command: string) => {
@@ -335,6 +310,4 @@ export const api = {
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }), setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"), tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }), 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) }),
}; };
@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

Before

Width:  |  Height:  |  Size: 711 B

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,15 @@
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.form { grid-template-columns: 1fr; }
.fullWidth { grid-column: auto; }
}
@@ -0,0 +1,30 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, SecretTextInput, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
import styles from "./ProviderEditor.module.scss";
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
value: ProviderInput;
headersText: string;
extraText: string;
editing: boolean;
onChange: (value: ProviderInput) => void;
onHeadersChange: (value: string) => void;
onExtraChange: (value: string) => void;
}) {
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
return <div className={styles.form}>
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><SecretTextInput autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
}
@@ -0,0 +1,14 @@
@use "../styles/typography" as type;
.badge {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.actions {
display: flex;
gap: 2px;
}
@@ -0,0 +1,31 @@
import type { Provider } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { editIcon, trashIcon } from "./ui/icons";
import styles from "./ProviderTable.module.scss";
const json = (value: unknown) => JSON.stringify(value);
export function ProviderTable({ providers, onEdit, onDelete }: {
providers: Provider[];
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
}) {
const columns: DataTableColumn<Provider>[] = [
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div> },
];
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
}
@@ -1,7 +1,5 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender"; import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../i18n/runtime";
import { useI18n } from "../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip"; import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss"; import styles from "./ContributionCalendarChart.module.scss";
@@ -56,6 +54,8 @@ const CALENDAR_CONFIG = {
axisLabelGap: 8, axisLabelGap: 8,
axisLabelWidth: 28, axisLabelWidth: 28,
} as const; } as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) { function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`); return new Date(`${date}T00:00:00Z`);
} }
@@ -72,10 +72,9 @@ function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell"; return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
} }
function buildCalendarLayout(data: ContributionDay[], locale: Locale) { function buildCalendarLayout(data: ContributionDay[]) {
if (data.length === 0) return null; if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens)); const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date); const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS); const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
@@ -89,22 +88,20 @@ function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => { const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date); const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`; const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column }); if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
return ticks; return ticks;
}, []); }, []);
return { cells, columnCount, monthTicks }; return { cells, columnCount, monthTicks };
} }
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) { export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null); const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null); const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined); const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null); const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]); const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]); const layout = useMemo(() => buildCalendarLayout(data), [data]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout; layoutRef.current = layout;
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1,11 +1,11 @@
import { BarChart, GaugeChart, LineChart } from "echarts/charts"; import { BarChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components"; import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core"; import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useRef, type MouseEventHandler } from "react"; import { useEffect, useRef, type MouseEventHandler } from "react";
import styles from "./EChart.module.scss"; import styles from "./EChart.module.scss";
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]); use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = { type EChartProps = {
option: EChartsCoreOption; option: EChartsCoreOption;
@@ -15,11 +15,12 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
const ready = useContext(CaReady); const ready = useContext(CaReady);
const { cursorHarness } = useAppStore(); const { cursorHarness } = useAppStore();
if (ready) return children; if (ready) return children;
const unsupported = cursorHarness?.ca === "unsupported";
const installedLocally = cursorHarness?.ca === "untrusted"; const installedLocally = cursorHarness?.ca === "untrusted";
return <div className={styles.gate}> return <div className={styles.gate}>
<strong>{installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong> <strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
<span>{installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span> <span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
<button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button> {!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
</div>; </div>;
} }
@@ -28,15 +29,12 @@ export function CursorModelProvider({ children }: { children: ReactNode }) {
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>; return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
} }
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) { export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
const ready = useContext(ModelsReady); const ready = useContext(ModelsReady);
if (ready) return children; if (ready) return children;
return <div className={styles.gate}> return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong> <strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。")}</span> <span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
<div className={styles.gateActions}> <button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
<button className={controls.primary} disabled={busy} onClick={onAdd}>{t("添加模型")}</button>
<button className={controls.secondary} disabled={busy} onClick={onImport}>{previewingImport ? t("读取中…") : t("导入旧版配置")}</button>
</div>
</div>; </div>;
} }
@@ -1,186 +0,0 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
onTest: (model: Model) => void;
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
</div>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
const reorder = useRef(onReorder);
currentModels.current = models;
reorder.current = onReorder;
useEffect(() => {
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
draggable: `.${styles.modelCard}`,
handle: `.${styles.sortHandle}`,
ghostClass: styles.sortGhost,
chosenClass: styles.sortChosen,
dragClass: styles.sortDragging,
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
onEnd: (event) => {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (typeof oldIndex !== "number"
|| typeof newIndex !== "number"
|| oldIndex === newIndex) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
const reordered = currentModels.current.slice();
const [moved] = reordered.splice(oldIndex, 1);
if (!moved || newIndex < 0 || newIndex > reordered.length) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
reordered.splice(newIndex, 0, moved);
reorder.current(reordered.map((model) => model.model_hash));
},
});
return () => {
sortable.current?.destroy();
sortable.current = null;
};
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
sortable.current?.sort(models.map((model) => model.model_hash), false);
}, [disabled, models]);
return <div ref={grid} className={styles.modelGrid}>
{models.map((model) => {
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
</span>
</div>
<div className={styles.modelCardTest}>
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
</div>
</div>
</Card>;
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -1,204 +1,101 @@
import type { ModelInput, ModelType } from "../../api"; import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls"; import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { Checkbox } from "../ui/Checkbox";
import { JsonEditor } from "../ui/JsonEditor"; import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, Select } from "../ui/Select"; import { Combobox, MultiCombobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch"; import { Switch } from "../ui/Switch";
import controls from "../ui/Controls.module.scss";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { claudeIcon, openAiIcon } from "../ui/icons"; import { claudeIcon, openAiIcon } from "../ui/icons";
import { CursorPresetChips } from "./CursorPresetChips"; import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
import styles from "./CursorSettings.module.scss"; import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = { export type CursorModelDraft = {
providerMode: string;
provider: ProviderInput;
model: ModelInput; model: ModelInput;
openAIExtraParamsText: string; modelIds: string[];
customHeadersText: string; headersText: string;
anthropicExtraParamsText: string; extraText: string;
customRequestUrl: boolean;
}; };
export const emptyCursorModelDraft = (): CursorModelDraft => ({ export const emptyCursorModelDraft = (): CursorModelDraft => ({
model: { providerMode: "new",
sort_order: 0, provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
display_name: "", model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
type: "openai", modelIds: [],
base_url: "", headersText: defaultCustomHeadersText,
use_full_url: false, extraText: "{}",
api_key: "", customRequestUrl: false,
tooltip_data: "",
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: null,
max_completion_tokens: null,
anthropic_max_tokens: null,
anthropic_thinking_effort: "xhigh",
thinking_budget_tokens: null,
},
openAIExtraParamsText: "{}",
customHeadersText: defaultCustomHeadersText,
anthropicExtraParamsText: "{}",
}); });
export function CursorModelEditor({ draft, modelOptions, discovering, onChange, onDiscover }: { export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft; draft: CursorModelDraft;
providers: Provider[];
editing: boolean;
modelOptions: string[]; modelOptions: string[];
discovering: boolean; discovering: boolean;
onChange: (draft: CursorModelDraft) => void; onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void; onDiscover: () => void;
}) { }) {
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } }); const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const setType = (type: ModelType) => { const canDiscover = draft.providerMode !== "new"
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点, || Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
// 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配 const selectProvider = (providerMode: string) => {
const other: ModelType = type === "anthropic" ? "openai" : "anthropic"; const endpointType = providerMode === "new"
const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim())); ? draft.provider.provider_type
const endpoint = preset ? presetEndpoint(preset, type) : null; : providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
onChange({ onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
...draft,
model: {
...draft.model,
type,
...(endpoint ? {
base_url: endpoint.baseUrl,
use_full_url: endpoint.useFullUrl,
custom_headers_enabled: endpoint.customHeaders !== null,
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
} : {}),
openai_endpoint: type === "openai" ? (endpoint?.openaiEndpoint || draft.model.openai_endpoint || "/v1/responses") : "",
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
},
customHeadersText: endpoint?.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
});
}; };
const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value)); const setEndpointType = (endpoint_type: ProviderType) => onChange({
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim()); ...draft,
// 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现) provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
const presetModelOptions = modelPresets model: { ...draft.model, endpoint_type },
.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 setModelIds = (modelIds: string[]) => onChange({
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])]; ...draft,
const applyPreset = (preset: ModelPreset) => { modelIds,
const endpoint = presetEndpoint(preset, draft.model.type); model: {
const first = preset.models[0]; ...draft.model,
// 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留 model_id: modelIds[0] ?? "",
const currentBase = trimTrailingSlash(draft.model.base_url.trim()); display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai] },
.some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase); });
onChange({
...draft,
model: {
...draft.model,
base_url: endpoint.baseUrl,
use_full_url: endpoint.useFullUrl,
openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint,
custom_headers_enabled: endpoint.customHeaders !== null,
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
api_key: sameProvider ? draft.model.api_key : "",
model_id: first?.model_id ?? draft.model.model_id,
display_name: first?.display_name ?? draft.model.display_name,
tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data,
context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens,
...(draft.model.type === "openai"
? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens }
: { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }),
},
customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
});
};
const requestUrlPlaceholder = draft.model.use_full_url
? draft.model.type === "anthropic"
? "https://api.anthropic.com/v1/messages"
: draft.model.openai_endpoint === "/v1/chat/completions"
? "https://api.openai.com/v1/chat/completions"
: "https://api.openai.com/v1/responses"
: draft.model.type === "anthropic"
? "https://api.anthropic.com"
: "https://api.openai.com";
return <div className={styles.editor}> return <div className={styles.editor}>
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} /> {!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
{ value: "new", label: t("新建上游") },
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
]} onChange={selectProvider} /></FormField>}
<div className={styles.grid}> <div className={styles.grid}>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[ {!editing && draft.providerMode === "new" && <>
{ value: "openai", label: "OpenAI", icon: openAiIcon }, <FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
{ value: "anthropic", label: "Anthropic", icon: claudeIcon }, <FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><SecretTextInput placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
]} onChange={(value) => setType(value as ModelType)} /></FormField>
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "/v1/responses", label: "Responses API" },
{ value: "/v1/chat/completions", label: "Chat Completions API" },
]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
<div className={styles.urlField}>
<FormField label={draft.model.use_full_url ? t("完整请求 URL") : t("服务器地址")} hint={draft.model.use_full_url ? t("系统会原样使用此地址,不追加或修改请求路径。") : t("系统会根据请求协议自动追加标准端点路径。")}> <TextInput placeholder={requestUrlPlaceholder} value={draft.model.base_url} onChange={(event) => setModel({ base_url: event.target.value })} /></FormField>
<Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
</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("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <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>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>} </>}
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
<ToggleJsonField { value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
label={t("自定义 Headers")} ]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
enabled={draft.model.custom_headers_enabled} {(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
text={draft.customHeadersText} <FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })} ? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })} : <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
/> }</FormField>
{draft.model.type === "openai" ? <ToggleJsonField <FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
label={t("OpenAI 额外参数")} <TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
enabled={draft.model.openai_extra_params_enabled} </FormField>
text={draft.openAIExtraParamsText} <div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })} {draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })} {!editing && draft.providerMode === "new" && <>
/> : <ToggleJsonField <FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
label={t("Anthropic 额外参数")} <FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
enabled={draft.model.anthropic_extra_params_enabled} </>}
text={draft.anthropicExtraParamsText} <div className={`${styles.switches} ${styles.fullWidth}`}>
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })} <label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })} <label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
/>} <label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
</div>
</div> </div>
</div>; </div>;
} }
function ToggleJsonField({ label, enabled, text, onEnabledChange, onTextChange }: {
label: string;
enabled: boolean;
text: string;
onEnabledChange: (enabled: boolean) => void;
onTextChange: (text: string) => void;
}) {
return <div className={`${styles.fullWidth} ${styles.jsonOption}`}>
<label><span>{label}</span><Switch label={label} checked={enabled} onChange={onEnabledChange} /></label>
{enabled && <JsonEditor ariaLabel={label} value={text} onChange={onTextChange} />}
</div>;
}
function effortOptions(optional: boolean) {
return [
...(optional ? [{ value: "", label: t("不设置") }] : []),
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
];
}
@@ -1,64 +0,0 @@
@use "../../styles/typography" as type;
.root {
min-width: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: type.$font-size-xs;
}
.idle {
color: var(--vscode-descriptionForeground);
background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent);
border-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
}
.testing {
color: var(--vscode-textLink-foreground);
background: color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-textLink-foreground) 24%, transparent);
}
.success {
color: var(--vscode-testing-iconPassed, #73c991);
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 11%, transparent);
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
}
.error {
color: var(--vscode-errorForeground, #f48771);
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
}
.summary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
color: currentColor;
background: transparent;
border: 0;
font: inherit;
cursor: help;
white-space: nowrap;
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
@@ -2,18 +2,13 @@ import type { ModelConnectivityResult } from "../../api";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
import { TooltipTrigger } from "../ui/TooltipTrigger"; import { TooltipTrigger } from "../ui/TooltipTrigger";
import { informationOutlineIcon } from "../ui/icons"; import { informationOutlineIcon } from "../ui/icons";
import styles from "./CursorModelTestResult.module.scss"; import styles from "./CursorSettings.module.scss";
export type CursorModelTestState = export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult } | { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string } | { status: "error"; error: string };
| { status: "cancelled" };
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
export function CursorModelTestResult({ state }: { state: CursorModelTestState }) {
const success = state.status === "success"; const success = state.status === "success";
const summary = success const summary = success
? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) }) ? t("速度:{speed} tokens/s", { speed: formatSpeed(state.result.tokens_per_second) })
@@ -29,9 +24,9 @@ export function CursorModelTestResult({ state, testing = false }: { state?: Curs
}) })
: t("测试失败:{error}", { error: state.error }); : t("测试失败:{error}", { error: state.error });
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}> return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}>
<span className={styles.summary}>{summary}</span> <span className={styles.testResultText}>{summary}</span>
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger> <TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger>
</div>; </div>;
} }
@@ -1,47 +0,0 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.label {
font-size: 12px;
color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7));
}
.chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16));
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
&:hover {
border-color: var(--vscode-focusBorder, #49b0ff);
}
&.active {
border-color: var(--vscode-focusBorder, #49b0ff);
color: var(--vscode-focusBorder, #49b0ff);
}
}
.icon {
width: 14px;
height: 14px;
border-radius: 3px;
object-fit: contain;
}
@@ -1,25 +0,0 @@
import type { ModelType } from "../../api";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import styles from "./CursorPresetChips.module.scss";
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) {
return <div className={styles.wrap}>
<span className={styles.label}>{t("常用预设")}</span>
<div className={styles.chips}>
{modelPresets.map((preset) => {
const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl);
return <button
type="button"
key={preset.key}
className={active ? `${styles.chip} ${styles.active}` : styles.chip}
title={preset.keyHint}
onClick={() => onPick(preset)}
>
<img className={styles.icon} src={preset.icon} alt="" />
{preset.name}
</button>;
})}
</div>
</div>;
}
@@ -5,35 +5,6 @@
gap: 16px; gap: 16px;
} }
.groupActions {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
.gateActions {
display: flex;
align-items: center;
gap: 8px;
}
.gate { .gate {
min-height: 250px; min-height: 250px;
display: flex; display: flex;
@@ -58,144 +29,103 @@
} }
} }
.modelGrid { .groups {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); flex-direction: column;
gap: 12px; gap: 12px;
} }
.modelGroups { .providerTitle {
display: flex; min-width: 0;
flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.modelGroupHeader {
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 6px; gap: 3px;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
}
.modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.modelCardTop {
min-width: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelCardName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.modelCardNameText, span {
.modelCardModelId {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.modelCardNameText { }
color: var(--vscode-foreground); .models {
font-size: type.$font-size-base; display: flex;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.modelCardModelId { small {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs; font-size: type.$font-size-xs;
} }
} }
.modelTypeBadge { .badge {
flex: 0 0 auto; padding: 2px 7px;
display: inline-flex; color: var(--vscode-badge-foreground);
align-items: center; background: var(--vscode-badge-background);
gap: 4px;
padding: 3px 7px;
color: var(--vscode-foreground);
border: 1px solid var(--vscode-sideBar-border);
border-radius: 999px; border-radius: 999px;
font-size: type.$font-size-2xs; font-size: type.$font-size-2xs;
} }
.modelCardTest { .rowActions {
min-height: 31px; display: flex;
gap: 2px;
}
.testResult {
min-width: 0;
max-width: 220px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px;
font-size: type.$font-size-xs;
} }
.modelCardActions { .testSuccess {
display: flex; color: var(--vscode-testing-iconPassed, #73c991);
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
} }
.deleteButton:hover { .testError {
color: var(--vscode-errorForeground, #f48771); color: var(--vscode-errorForeground, #f48771);
} }
.sortHandle { .testResultText {
position: absolute; min-width: 0;
top: 8px; overflow: hidden;
left: 8px; text-overflow: ellipsis;
z-index: 1; white-space: nowrap;
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
color: transparent;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
opacity: 0;
cursor: grab;
touch-action: none;
transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease, background-color 160ms ease;
&:focus-visible,
.modelCard:hover & {
color: var(--vscode-foreground);
background: var(--vscode-list-hoverBackground);
border-color: var(--vscode-sideBar-border);
opacity: 1;
}
&:active {
cursor: grabbing;
}
&:disabled {
cursor: not-allowed;
opacity: 0.3;
}
} }
.sortGhost { .testResultHint {
opacity: 0.4; flex: 0 0 auto;
} display: flex;
.sortChosen { align-items: center;
border-color: var(--vscode-focusBorder); justify-content: center;
} color: currentColor;
.sortDragging { cursor: help;
cursor: grabbing; &:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
} }
.editor { .editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.editorTestResult {
margin-top: 14px;
}
.command { .command {
margin: 0; margin: 0;
padding: 10px; padding: 10px;
@@ -215,17 +145,6 @@
width: 100%; width: 100%;
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.discoverButton {
min-height: 34px;
height: 34px;
flex: 0 0 auto;
}
.urlField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.switches { .switches {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -241,21 +160,6 @@
} }
} }
.jsonOption {
display: flex;
flex-direction: column;
gap: 8px;
> label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -0,0 +1,51 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -0,0 +1,53 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -15,8 +15,8 @@
top: var(--app-content-top); top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2)); left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0; margin: 0;
padding-left:0; padding-left: 10px;
font-size: type.$font-size-xxl; font-size: type.$font-size-lg;
font-weight: bolder; font-weight: bolder;
transform: translateY(-150%); transform: translateY(-150%);
-webkit-user-select: none; -webkit-user-select: none;
@@ -26,8 +26,8 @@
top: 0; top: 0;
margin: 0; margin: 0;
padding-left: 0; padding-left: 10px;
font-size: type.$font-size-xxl; font-size: type.$font-size-lg;
font-weight: bolder; font-weight: bolder;
transform: translateY(-150%); transform: translateY(-150%);
} }
@@ -1,22 +1,18 @@
@use "../../styles/typography" as type; @use "../../styles/typography" as type;
.root { .root {
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
position: relative; position: relative;
width: 200px; width: 132px;
max-width: 100%;
height: 82px; height: 82px;
overflow: hidden;
align-self: center; align-self: center;
flex: 0 0 auto; flex: 0 0 auto;
} }
.chart { .canvas {
position: absolute; width: 100% !important;
top: 0; height: 100% !important;
left: 0;
width: 100%;
height: 140px;
pointer-events: none;
} }
.label { .label {
@@ -1,62 +1,75 @@
import type { EChartsCoreOption } from "echarts/core"; import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { EChart } from "../charts/EChart"; import { Doughnut } from "react-chartjs-2";
import styles from "./CacheHitRateChart.module.scss"; import styles from "./CacheHitRateChart.module.scss";
const valueColor = "#40c463"; ChartJS.register(ArcElement, Tooltip);
const trackColor = "rgba(139, 148, 158, 0.20)";
export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: number }) { type SegmentRadius = number | {
outerStart: number;
outerEnd: number;
innerStart: number;
innerEnd: number;
};
function chartColor(context: ScriptableContext<"doughnut">) {
const styles = getComputedStyle(context.chart.canvas);
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
return styles.getPropertyValue(variable).trim();
}
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
const radius = 5;
if (percentage <= 0) {
return dataIndex === 1
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
if (percentage >= 100) {
return dataIndex === 0
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
return dataIndex === 0
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
}
const options: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: { duration: 450 },
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
};
export function CacheHitRateChart({ rate }: { rate: number }) {
const finiteRate = Number.isFinite(rate) ? rate : 0; const finiteRate = Number.isFinite(rate) ? rate : 0;
const percentage = Math.max(0, Math.min(100, finiteRate * 100)); const percentage = Math.max(0, Math.min(100, finiteRate * 100));
const [displayedPercentage, setDisplayedPercentage] = useState(0);
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--"; const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
const data = useMemo(() => ({
useEffect(() => { labels: [t("命中"), t("未命中")],
setDisplayedPercentage(0); datasets: [{
let frame = requestAnimationFrame(() => { data: [percentage, Math.max(0, 100 - percentage)],
frame = requestAnimationFrame(() => setDisplayedPercentage(percentage)); backgroundColor: chartColor,
}); borderWidth: 0,
return () => cancelAnimationFrame(frame); hoverBorderWidth: 0,
}, [animationKey, percentage]); selfJoin: false,
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 0,
animationDurationUpdate: displayedPercentage > 0 ? 1_000 : 0,
animationEasing: "cubicOut",
animationEasingUpdate: "cubicOut",
series: [{
type: "gauge",
min: 0,
max: 100,
startAngle: 180,
endAngle: 0,
center: ["50%", "50%"],
radius: "90%",
silent: true,
pointer: { show: false },
progress: {
show: true,
roundCap: true,
width: 11,
itemStyle: { color: displayedPercentage > 0 ? valueColor : "transparent" },
},
axisLine: {
roundCap: true,
lineStyle: { width: 11, color: [[1, trackColor]] },
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
title: { show: false },
detail: { show: false },
data: [{ value: displayedPercentage }],
}], }],
}), [displayedPercentage]); }), [percentage]);
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}> return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
<EChart className={styles.chart} option={option} /> <Doughnut className={styles.canvas} data={data} options={options} />
<div className={styles.label}>{label}</div> <div className={styles.label}>{label}</div>
</div>; </div>;
} }
@@ -71,7 +71,7 @@ function InfoTooltip({ content }: { content: string }) {
</>; </>;
} }
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) { export function HomeMetrics({ data }: { data: HomeMetricsData }) {
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens); const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens); const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens); const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
@@ -148,7 +148,7 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat
<section className={styles.root} aria-label={t("调用统计")}> <section className={styles.root} aria-label={t("调用统计")}>
<article className={styles.metric}> <article className={styles.metric}>
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div> <div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} /> <CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
</article> </article>
<article className={styles.metric}> <article className={styles.metric}>
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div> <div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
@@ -1,92 +0,0 @@
@use "../../styles/typography" as type;
.content {
display: grid;
gap: 14px;
}
.source {
display: grid;
gap: 6px;
code {
overflow-wrap: anywhere;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
> div {
display: grid;
gap: 3px;
padding: 10px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.models {
display: grid;
gap: 6px;
}
.model {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 9px 10px;
background: var(--vscode-list-inactiveSelectionBackground);
border-radius: 5px;
> div {
display: grid;
min-width: 0;
gap: 3px;
}
small {
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
text-overflow: ellipsis;
white-space: nowrap;
}
> span {
flex: 0 0 auto;
font-size: type.$font-size-xs;
}
}
.new {
color: var(--vscode-testing-iconPassed, #73c991);
}
.existing,
.hint {
color: var(--vscode-descriptionForeground);
}
.hint {
font-size: type.$font-size-xs;
}
@media (max-width: 680px) {
.model {
align-items: stretch;
flex-direction: column;
}
}
@@ -1,92 +0,0 @@
import { useState, type ReactNode } from "react";
import { api, type LegacyModelImportPreview } from "../../api";
import { appStore } from "../../store/appStore";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { useMessage } from "../ui/message";
import styles from "./LegacyModelImport.module.scss";
type LegacyModelImportControl = {
busy: boolean;
previewing: boolean;
open: () => void;
};
export function LegacyModelImport({ children }: { children: (control: LegacyModelImportControl) => ReactNode }) {
const message = useMessage();
const [preview, setPreview] = useState<LegacyModelImportPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [importing, setImporting] = useState(false);
const open = async () => {
try {
setPreviewing(true);
setPreview(await api.previewV0049Models());
} catch (cause) {
message(errorText(cause));
} finally {
setPreviewing(false);
}
};
const confirm = async () => {
try {
setImporting(true);
const result = await appStore.importV0049Models();
if (!result) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setPreview(null);
message(result.imported > 0
? t("导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", { imported: result.imported, skipped: result.skipped })
: t("配置中的 {count} 个模型均已存在,无需重复导入", { count: result.skipped }));
} catch (cause) {
message(errorText(cause));
} finally {
setImporting(false);
}
};
return <>
{children({ busy: previewing || importing, previewing, open: () => void open() })}
<ConfirmDialog
open={preview !== null}
title={t("确认导入旧版模型配置")}
busy={importing}
wide
cancelLabel={t("取消")}
confirmLabel={t("确认导入")}
onCancel={() => setPreview(null)}
onConfirm={() => void confirm()}
>
{preview && <div className={styles.content}>
<div className={styles.source}>
<strong>{t("配置文件")}</strong>
<code>{preview.source}</code>
</div>
<div className={styles.counts}>
<div><strong>{preview.total}</strong><small>{t("配置模型")}</small></div>
<div><strong>{preview.new_models}</strong><small>{t("将新增")}</small></div>
<div><strong>{preview.existing_models}</strong><small>{t("已存在")}</small></div>
</div>
<div className={styles.models}>
{preview.models.map((model) => <div className={styles.model} key={model.model_hash}>
<div>
<strong>{model.display_name}</strong>
<small>{model.model_id} · {model.type === "openai" ? "OpenAI" : "Anthropic"}</small>
</div>
<span className={model.existing ? styles.existing : styles.new}>
{model.existing ? t("已存在,跳过") : t("新增")}
</span>
</div>)}
</div>
<small className={styles.hint}>{t("重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。")}</small>
</div>}
</ConfirmDialog>
</>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : String(cause);
}
@@ -18,7 +18,7 @@
border-radius: 5px; border-radius: 5px;
button { button {
height: 25px; height: 30px;
padding: 0 8px; padding: 0 8px;
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
background: transparent; background: transparent;
@@ -11,19 +11,22 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom"; export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: { export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset; value: OverviewRangePreset;
customOpen: boolean; customOpen: boolean;
customStart: string; customStart: string;
customEnd: string; customEnd: string;
modelOptions: MultiSelectOption[]; modelOptions: MultiSelectOption[];
providerOptions: MultiSelectOption[];
selectedModels: string[]; selectedModels: string[];
selectedProviders: string[];
busy: boolean; busy: boolean;
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void; onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
onCustomOpenChange: (open: boolean) => void; onCustomOpenChange: (open: boolean) => void;
onCustomStartChange: (value: string) => void; onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void; onCustomEndChange: (value: string) => void;
onSelectedModelsChange: (value: string[]) => void; onSelectedModelsChange: (value: string[]) => void;
onSelectedProvidersChange: (value: string[]) => void;
onCustomApply: () => void; onCustomApply: () => void;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
@@ -106,6 +109,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
<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 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> <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}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
<div className={styles.popoverActions}> <div className={styles.popoverActions}>
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button> <button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button> <button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
@@ -97,7 +97,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const hostRef = useRef<HTMLDivElement | null>(null) const hostRef = useRef<HTMLDivElement | null>(null)
const localViewportRef = useRef<HTMLDivElement | null>(null) const localViewportRef = useRef<HTMLDivElement | null>(null)
const localContentRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null) const trackRef = useRef<HTMLDivElement | null>(null)
const thumbRef = useRef<HTMLDivElement | null>(null) const thumbRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{ const dragRef = useRef<{
@@ -108,7 +107,6 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const { state, api, metricsRef, scrollToNow } = useScrollableModel({ const { state, api, metricsRef, scrollToNow } = useScrollableModel({
viewportRef: localViewportRef, viewportRef: localViewportRef,
contentRef: localContentRef,
trackRef, trackRef,
thumbRef, thumbRef,
contentHeight, contentHeight,
@@ -134,10 +132,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
) )
const setContentRef = useCallback( const setContentRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => assignRef(contentRef, node),
localContentRef.current = node
assignRef(contentRef, node)
},
[contentRef] [contentRef]
) )
@@ -33,7 +33,6 @@ export interface ScrollMetrics extends ScrollAreaState {
interface UseScrollableModelOptions { interface UseScrollableModelOptions {
viewportRef: RefObject<HTMLDivElement | null> viewportRef: RefObject<HTMLDivElement | null>
contentRef: RefObject<HTMLDivElement | null>
trackRef: RefObject<HTMLDivElement | null> trackRef: RefObject<HTMLDivElement | null>
thumbRef: RefObject<HTMLDivElement | null> thumbRef: RefObject<HTMLDivElement | null>
contentHeight?: number contentHeight?: number
@@ -132,7 +131,6 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) { export function useScrollableModel(options: UseScrollableModelOptions) {
const { const {
viewportRef, viewportRef,
contentRef,
trackRef, trackRef,
thumbRef, thumbRef,
contentHeight, contentHeight,
@@ -321,30 +319,28 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
useEffect(() => { useEffect(() => {
const element = viewportRef.current const element = viewportRef.current
const content = contentRef.current
const track = trackRef.current const track = trackRef.current
if (!element || !content || !track) return if (!element || !track) return
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
const viewportEntry = entries.find((entry) => entry.target === element) const entry = entries[0]
if (viewportEntry) { if (entry) {
onViewportResizeRef.current?.({ onViewportResizeRef.current?.({
width: viewportEntry.contentRect.width, width: entry.contentRect.width,
height: viewportEntry.contentRect.height, height: entry.contentRect.height,
}) })
} }
snapshot(false) snapshot(false)
}) })
resizeObserver.observe(element) resizeObserver.observe(element)
resizeObserver.observe(content)
resizeObserver.observe(track) resizeObserver.observe(track)
snapshot(false) snapshot(false)
return () => { return () => {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
}, [contentRef, snapshot, trackRef, viewportRef]) }, [snapshot, trackRef, viewportRef])
useEffect(() => { useEffect(() => {
snapshot(false) snapshot(false)
@@ -1,13 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
currentAppVersion, currentAppVersion,
hasDockVisibilitySetting,
hasNativeAppLifecycle, hasNativeAppLifecycle,
readAutostart, readAutostart,
readDesktopSettings,
writeAutostart, writeAutostart,
writeDockIconVisibility,
writeSilentStart,
} from "../../native/appLifecycle"; } from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore"; import { updateStore, useUpdateStore } from "../../store/updateStore";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
@@ -19,14 +15,10 @@ import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() { export function AppLifecycleSettingsCard() {
const message = useMessage(); const message = useMessage();
const native = hasNativeAppLifecycle(); const native = hasNativeAppLifecycle();
const dockVisibilitySetting = hasDockVisibilitySetting();
const { availableVersion, checking, installing } = useUpdateStore(); const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…"); const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false); const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native); const [loadingAutostart, setLoadingAutostart] = useState(native);
const [silentStart, setSilentStart] = useState(false);
const [dockIconVisible, setDockIconVisible] = useState(true);
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -36,17 +28,6 @@ export function AppLifecycleSettingsCard() {
.then((enabled) => { if (!disposed) setAutostart(enabled); }) .then((enabled) => { if (!disposed) setAutostart(enabled); })
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause))) .catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); }); .finally(() => { if (!disposed) setLoadingAutostart(false); });
void readDesktopSettings()
.then((settings) => {
if (disposed) return;
setSilentStart(settings.silent_start);
setDockIconVisible(settings.show_dock_icon);
})
.catch(() => {})
.finally(() => {
if (disposed) return;
setLoadingDesktopSettings(false);
});
} }
return () => { disposed = true; }; return () => { disposed = true; };
}, [message, native]); }, [message, native]);
@@ -64,32 +45,6 @@ export function AppLifecycleSettingsCard() {
} }
}; };
const toggleSilentStart = async (enabled: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeSilentStart(enabled);
setSilentStart(enabled);
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const toggleDockIcon = async (visible: boolean) => {
try {
setLoadingDesktopSettings(true);
await writeDockIconVisibility(visible);
setDockIconVisible(visible);
message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingDesktopSettings(false);
}
};
const checkUpdate = async () => { const checkUpdate = async () => {
try { try {
const nextVersion = await updateStore.check(); const nextVersion = await updateStore.check();
@@ -120,30 +75,6 @@ export function AppLifecycleSettingsCard() {
onChange={(enabled) => void toggleAutostart(enabled)} onChange={(enabled) => void toggleAutostart(enabled)}
/> />
</div> </div>
{autostart && <div className={styles.row}>
<div>
<strong>{t("静默启动")}</strong>
<small>{t("开机启动时不显示主窗口,仅保留系统托盘图标。")}</small>
</div>
<Switch
checked={silentStart}
disabled={!native || loadingDesktopSettings}
label={t("静默启动")}
onChange={(enabled) => void toggleSilentStart(enabled)}
/>
</div>}
{dockVisibilitySetting && <div className={styles.row}>
<div>
<strong>{t("在 Dock 栏显示")}</strong>
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
</div>
<Switch
checked={dockIconVisible}
disabled={loadingDesktopSettings}
label={t("在 Dock 栏显示")}
onChange={(visible) => void toggleDockIcon(visible)}
/>
</div>}
<div className={styles.row}> <div className={styles.row}>
<div> <div>
<strong>{t("软件更新")}</strong> <strong>{t("软件更新")}</strong>
@@ -1,78 +0,0 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.content {
display: grid;
gap: 14px;
padding: 16px;
}
.row {
min-width: 0;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.control {
width: min(420px, 62%);
}
.value {
min-width: 0;
max-width: 70%;
color: var(--vscode-descriptionForeground);
overflow-wrap: anywhere;
text-align: right;
}
.headerAction {
padding: 4px;
color: var(--vscode-textLink-foreground);
background: transparent;
border: 0;
font-size: type.$font-size-xs;
&:hover { color: var(--vscode-textLink-activeForeground); }
&:disabled { opacity: .5; }
}
.actionGroup {
display: flex;
align-items: center;
gap: 6px;
}
@media (max-width: 680px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.control,
.value {
width: 100%;
max-width: none;
text-align: left;
}
}
@@ -1,90 +0,0 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({
settings,
draft,
editing,
saving,
onDraftChange,
onEdit,
onCancel,
onSave,
}: {
settings: TabSettings | null;
draft: TabSettings;
editing: boolean;
saving: boolean;
onDraftChange: (settings: TabSettings) => void;
onEdit: () => void;
onCancel: () => void;
onSave: () => void;
}) {
const modeLabel = (mode: TabMode) => {
if (mode === "public") return t("使用公益服务");
if (mode === "direct") return t("直连");
return t("自定义");
};
const action = editing ? (
<div className={styles.actionGroup}>
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
</div>
) : (
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>{t("编辑")}</button>
);
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={action}
>
<div className={styles.content}>
{editing ? <>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.control}><Select
value={draft.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onDraftChange({ ...draft, mode: mode as TabMode })}
/></div>
</div>
{draft.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<div className={styles.control}><TextInput
value={draft.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onDraftChange({ ...draft, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/></div>
</div>}
</> : <>
<div className={styles.row}>
<strong>{t("TAB 选择")}</strong>
<span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span>
</div>
{settings?.mode === "custom" && <div className={styles.row}>
<strong>{t("TAB 服务地址")}</strong>
<span className={styles.value}>{settings.address}</span>
</div>}
</>}
</div>
</TitledCard>;
}
+5 -4
View File
@@ -1,11 +1,12 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react"; import type { ElementType, ReactNode } from "react";
import styles from "./Card.module.scss"; import styles from "./Card.module.scss";
type CardProps = HTMLAttributes<HTMLElement> & { type CardProps = {
as?: ElementType; as?: ElementType;
className?: string;
children: ReactNode; children: ReactNode;
}; };
export function Card({ as: Component = "div", className, children, ...props }: CardProps) { export function Card({ as: Component = "div", className, children }: CardProps) {
return <Component {...props} className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>; return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
} }
@@ -8,7 +8,6 @@
color: var(--vscode-foreground); color: var(--vscode-foreground);
font-size: type.$font-size-xs; font-size: type.$font-size-xs;
cursor: pointer; cursor: pointer;
flex-shrink: 0;
input { input {
position: absolute; position: absolute;
@@ -1,33 +0,0 @@
import { useId, type ReactNode } from "react";
import { Modal } from "./Modal";
export function ConfirmDialog({ id, open, title, children, busy, wide, cancelLabel = t("取消"), confirmLabel = t("确认"), onCancel, onConfirm }: {
id?: string;
open: boolean;
title: string;
children: ReactNode;
busy?: boolean;
wide?: boolean;
cancelLabel?: string;
confirmLabel?: string;
onCancel: () => void;
onConfirm: () => void;
}) {
const contentId = useId();
return <Modal
id={id}
open={open}
title={title}
busy={busy}
wide={wide}
role="alertdialog"
ariaDescribedBy={contentId}
initialFocus="submit"
closeLabel={cancelLabel}
submitLabel={confirmLabel}
onClose={onCancel}
onSubmit={onConfirm}
>
<div id={contentId}>{children}</div>
</Modal>;
}
@@ -34,8 +34,8 @@
} }
.iconButton { .iconButton {
width: 25px; width: 30px;
height: 25px; height: 30px;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 0; padding: 0;
@@ -47,16 +47,6 @@
.input { .input {
height: 34px; height: 34px;
padding: 0 10px; padding: 0 10px;
&[type="number"] {
appearance: textfield;
}
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
} }
.secret { .secret {
@@ -34,14 +34,12 @@
box-shadow: 0 20px 50px rgb(0 0 0 / 45%); box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
header { header {
flex: 0 0 auto;
padding: 10px 18px; padding: 10px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border); border-bottom: 1px solid var(--vscode-editorWidget-border);
font-size: type.$font-size-base; font-size: type.$font-size-base;
font-weight: 630; font-weight: 630;
} }
footer { footer {
flex: 0 0 auto;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
@@ -54,19 +52,15 @@
width: min(1080px, calc(100vw - 48px)); width: min(1080px, calc(100vw - 48px));
} }
.fullHeight {
height: calc(100vh - var(--window-header-height) - 24px);
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.body { .body {
flex: 1 1 auto; flex: 0 1 auto;
min-height: 0; min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyViewport {
height: auto;
max-height: calc(100vh - var(--window-header-height) - 112px);
} }
.bodyContent { .bodyContent {
+9 -74
View File
@@ -1,101 +1,36 @@
import { useEffect, useId, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { ScrollableContent } from "../virtual/ScrollableContent"; import { ScrollableContent } from "../virtual/ScrollableContent";
import controls from "./Controls.module.scss"; import controls from "./Controls.module.scss";
import styles from "./Modal.module.scss"; import styles from "./Modal.module.scss";
type ModalProps = { export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; secondaryAction?: ReactNode; closeLabel?: string; submitLabel?: string }) {
id?: string;
open: boolean;
title: string;
children: ReactNode;
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
fullHeight?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
onClose: () => void;
onSubmit?: () => void;
secondaryAction?: ReactNode;
closeLabel?: string;
submitLabel?: string;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function focusableElements(root: HTMLElement) {
return [...root.querySelectorAll<HTMLElement>(focusableSelector)]
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null); const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose); const closeRef = useRef(onClose);
const busyRef = useRef(Boolean(busy)); const busyRef = useRef(Boolean(busy));
const titleId = useId();
closeRef.current = onClose; closeRef.current = onClose;
busyRef.current = Boolean(busy); busyRef.current = Boolean(busy);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const previous = document.activeElement as HTMLElement | null; const previous = document.activeElement as HTMLElement | null;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); };
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
closeRef.current();
return;
}
if (event.key !== "Tab" || event.defaultPrevented || !dialog.current) return;
const focusable = focusableElements(dialog.current);
if (!focusable.length) {
event.preventDefault();
dialog.current.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1)!;
const active = document.activeElement;
if (event.shiftKey && (active === first || !dialog.current.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !dialog.current.contains(active))) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);
const focusFrame = requestAnimationFrame(() => { requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus());
const currentDialog = dialog.current;
if (!currentDialog) return;
const target = initialFocus === "submit" ? submitButton.current : focusableElements(currentDialog)[0];
(target ?? currentDialog).focus();
});
return () => { return () => {
cancelAnimationFrame(focusFrame);
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
if (previous && document.contains(previous)) previous.focus(); if (previous && document.contains(previous)) previous.focus();
}; };
}, [initialFocus, open]); }, [open]);
if (!open) return null; if (!open) return null;
return createPortal(<div className={styles.mask}> return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" /> <div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}> <div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}>
<header id={titleId}>{title}</header> <header>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>} <ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer> <footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button> <button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction} {secondaryAction}
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>} {onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer> </footer>
</div> </div>
</div>, document.body); </div>, document.body);
+1 -2
View File
@@ -5,7 +5,7 @@ import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement; export type TooltipAnchor = VirtualElement;
export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) { export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) {
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null); const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
@@ -32,7 +32,6 @@ export function Tooltip({ id, anchor, children }: { id?: string; anchor: Tooltip
return createPortal( return createPortal(
<div <div
ref={tooltipRef} ref={tooltipRef}
id={id}
className={styles.root} className={styles.root}
role="tooltip" role="tooltip"
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }} style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
@@ -1,4 +1,4 @@
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react"; import { cloneElement, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react";
import { Tooltip, type TooltipAnchor } from "./Tooltip"; import { Tooltip, type TooltipAnchor } from "./Tooltip";
function anchorFor(element: HTMLElement): TooltipAnchor { function anchorFor(element: HTMLElement): TooltipAnchor {
@@ -6,43 +6,26 @@ function anchorFor(element: HTMLElement): TooltipAnchor {
} }
type TriggerProps = { type TriggerProps = {
"aria-describedby"?: string; onMouseEnter?: MouseEventHandler<HTMLElement>;
onPointerMove?: PointerEventHandler<HTMLElement>; onMouseLeave?: MouseEventHandler<HTMLElement>;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onPointerDown?: PointerEventHandler<HTMLElement>;
onFocus?: FocusEventHandler<HTMLElement>; onFocus?: FocusEventHandler<HTMLElement>;
onBlur?: FocusEventHandler<HTMLElement>; onBlur?: FocusEventHandler<HTMLElement>;
}; };
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) { export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null); const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const tooltipId = useId();
useEffect(() => {
if (!anchor) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [anchor]);
const trigger = cloneElement(children, { const trigger = cloneElement(children, {
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined, onMouseEnter: (event) => {
onPointerMove: (event) => { children.props.onMouseEnter?.(event);
children.props.onPointerMove?.(event); setAnchor(anchorFor(event.currentTarget));
if (event.pointerType !== "touch" && event.buttons === 0) {
const element = event.currentTarget;
setAnchor((current) => current ?? anchorFor(element));
}
}, },
onPointerLeave: (event) => { onMouseLeave: (event) => {
children.props.onPointerLeave?.(event); children.props.onMouseLeave?.(event);
setAnchor(null);
},
onPointerDown: (event) => {
children.props.onPointerDown?.(event);
setAnchor(null); setAnchor(null);
}, },
onFocus: (event) => { onFocus: (event) => {
children.props.onFocus?.(event); children.props.onFocus?.(event);
setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null); setAnchor(anchorFor(event.currentTarget));
}, },
onBlur: (event) => { onBlur: (event) => {
children.props.onBlur?.(event); children.props.onBlur?.(event);
@@ -52,6 +35,6 @@ export function TooltipTrigger({ label, children }: { label: string; children: R
return <> return <>
{trigger} {trigger}
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip> <Tooltip anchor={anchor}>{label}</Tooltip>
</>; </>;
} }
+1 -1
View File
@@ -15,6 +15,7 @@ export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257); export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>'); export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
// Menu icons intentionally use filled glyphs from different collections so they // Menu icons intentionally use filled glyphs from different collections so they
// can be compared in context before settling on one visual family. // can be compared in context before settling on one visual family.
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>'); export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
@@ -31,7 +32,6 @@ export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2
export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline
export const dragIcon = icon('<path fill="currentColor" d="M7 4h2v2H7zm8 0h2v2h-2zM7 11h2v2H7zm8 0h2v2h-2zM7 18h2v2H7zm8 0h2v2h-2z"/>'); // mdi:drag-vertical
export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>'); export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>');
export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>'); export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>');
export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>'); export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>');
-287
View File
@@ -1,287 +0,0 @@
import type {
CallDetail,
CursorHarnessStatus,
LlmCall,
Model,
Overview,
OverviewTokenUsageBucket,
ProxySettings,
StatisticsStorage,
TabSettings,
} from "../api";
const API_ROOT = "/__byok-api__/api";
const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0);
const models: Model[] = [
createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }),
createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }),
createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }),
createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }),
createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }),
createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }),
createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }),
createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }),
createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }),
createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }),
];
const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
const model = models[index % models.length];
const failed = index === 7 || index === 19;
return {
call_kind: "provider_llm",
route: "local_byok",
call_id: `mock-call-${String(index + 1).padStart(3, "0")}`,
run_id: `mock-run-${Math.floor(index / 3) + 1}`,
conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`,
provider_call_index: index + 1,
model_hash: model.model_hash,
provider_type: model.type,
provider_url: model.base_url,
request_type: model.type === "anthropic" ? "messages" : "responses",
request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`,
model_id: model.model_id,
display_name: model.display_name,
reasoning_effort: index % 2 === 0 ? "high" : null,
fast: index % 3 === 0,
status: failed ? "failed" : "completed",
finish_reason: failed ? null : "stop",
created_at_ms: FIXED_NOW - index * 3 * 60_000,
ttfb_ms: 210 + index * 13,
ttft_ms: 370 + index * 17,
duration_ms: failed ? 812 : 1_420 + index * 71,
input_tokens: 4_800 + index * 337,
output_tokens: failed ? 0 : 820 + index * 43,
total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380,
cache_read_tokens: 3_100 + index * 251,
cache_write_tokens: 320 + index * 19,
reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null,
message_count: 14 + (index % 8),
tool_count: 3 + (index % 5),
http_status: failed ? 429 : 200,
error_kind: failed ? "provider_rate_limit" : null,
error_message: failed ? "Mock provider rate limit" : null,
detailed: true,
};
});
const harnessStatus: CursorHarnessStatus = {
platform: "macos",
ca: "ready",
configured_models: models.length,
enabled_models: models.length,
integration: "enabled",
proxy_url: "http://127.0.0.1:54321",
ca_install_command: null,
};
let detailed = true;
let portSettings = { proxy_port: 0, service_port: 0 };
let proxySettings: ProxySettings = {
mode: "system",
address: "",
auth_enabled: false,
username: "",
has_password: false,
};
let tabSettings: TabSettings = { mode: "public", address: "" };
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
export function installDemoApi() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
const url = new URL(requestUrl, window.location.href);
if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init);
const path = url.pathname.slice(API_ROOT.length) || "/";
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
const body = await readBody(input, init);
if (path === "/ads") return json({ slots: [] });
if (path === "/models" && method === "GET") return json(models);
if (path === "/models" && method === "POST") return json(models);
if (path === "/models/order") return json(models);
if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) });
if (path === "/models/import-v0049" && method === "GET") {
return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] });
}
if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 });
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") {
return json({ duration_ms: 1_284, first_text_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." });
}
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) {
return method === "DELETE" ? empty() : json(models[0]);
}
if (path === "/overview") return json(createOverview(url.searchParams));
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 === "/settings/observability" && method === "GET") return json({ detailed });
if (path === "/settings/observability") {
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
return json({ detailed });
}
if (path === "/settings/ports" && method === "GET") return json(portSettings);
if (path === "/settings/ports") {
portSettings = body as typeof portSettings;
return json(portSettings);
}
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
if (path === "/settings/storage/statistics") {
storage = { bytes: 0, call_count: 0, trace_count: 0 };
return json(storage);
}
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
if (path === "/settings/proxy") {
const next = body as Partial<ProxySettings>;
proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) };
return json(proxySettings);
}
if (path === "/settings/tab" && method === "GET") return json(tabSettings);
if (path === "/settings/tab") {
tabSettings = body as TabSettings;
return json(tabSettings);
}
if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true });
if (path === "/settings/desktop") return json(body);
if (path === "/desktop/open-external-url") {
const target = (body as { url?: string } | null)?.url;
if (target) {
const next = new URL(target, window.location.href);
if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash;
}
return empty();
}
if (path.endsWith("/dismissals")) return empty();
return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404);
};
}
function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: {
hash: string;
order: number;
name: string;
type: Model["type"];
url: string;
modelId: string;
endpoint?: string;
}): Model {
return {
model_hash: hash,
sort_order: order,
display_name: name,
type,
base_url: url,
use_full_url: false,
api_key: "demo-key",
tooltip_data: `${name} Mock 通道`,
model_id: modelId,
reasoning_effort: type === "openai" ? "high" : null,
openai_endpoint: type === "openai" ? endpoint : "",
openai_extra_params_enabled: false,
openai_extra_params: {},
custom_headers_enabled: false,
custom_headers: {},
anthropic_extra_params_enabled: false,
anthropic_extra_params: {},
context_window_tokens: 200_000,
max_completion_tokens: type === "openai" ? 32_000 : null,
anthropic_max_tokens: type === "anthropic" ? 32_000 : null,
anthropic_thinking_effort: type === "anthropic" ? "high" : null,
thinking_budget_tokens: null,
created_at_ms: FIXED_NOW - order * 86_400_000,
updated_at_ms: FIXED_NOW,
};
}
function createOverview(params: URLSearchParams): Overview {
const start = Number(params.get("start_ms"));
const end = Number(params.get("end_ms"));
const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000;
const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day";
const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000;
const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step)));
const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW);
const totals = series.reduce((sum, bucket) => ({
input: sum.input + bucket.input_tokens,
cacheRead: sum.cacheRead + bucket.cache_read_tokens,
cacheWrite: sum.cacheWrite + bucket.cache_write_tokens,
output: sum.output + bucket.output_tokens,
}), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
const llmCalls = Math.max(12, Math.round(count * 5.4));
return {
metrics: {
llm_calls: llmCalls,
successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)),
failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)),
token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output,
prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite,
input_tokens: totals.input,
cache_read_tokens: totals.cacheRead,
cache_write_tokens: totals.cacheWrite,
output_tokens: totals.output,
},
token_usage_granularity: granularity,
token_usage_series: series,
};
}
function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] {
return Array.from({ length: count }, (_, index) => {
const wave = 0.72 + ((index * 17) % 31) / 50;
return {
bucket_start_ms: end - (count - index) * step,
input_tokens: Math.round(18_000 * wave),
cache_read_tokens: Math.round(62_000 * wave),
cache_write_tokens: Math.round(7_500 * wave),
output_tokens: Math.round(12_500 * wave),
};
});
}
function createCallDetail(id: string): CallDetail {
const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0];
return {
call,
request: {
headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" },
body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] },
byte_count: 8_426,
},
response_chunks: [
{ seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 },
{ seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 },
],
cursor_trace: null,
};
}
async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise<unknown> {
const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null);
if (typeof raw !== "string" || raw.length === 0) return null;
try { return JSON.parse(raw) as unknown; }
catch { return raw; }
}
function json(value: unknown, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}
function empty() {
return new Response(null, { status: 204 });
}
-8
View File
@@ -1,8 +0,0 @@
import { installDemoApi } from "./api";
installDemoApi();
document.documentElement.dataset.platform = "macos";
localStorage.setItem("cursor-byok.locale", "zh-CN");
localStorage.setItem("cursor-byok.theme", "default-dark");
void import("../index");
File diff suppressed because it is too large Load Diff
+58 -74
View File
@@ -1,25 +1,27 @@
{ {
"0006d696d8e1ec28": "New",
"00929f23850e4ff0": "Successful calls: {count}", "00929f23850e4ff0": "Successful calls: {count}",
"01f3e69a5a9b2c9b": "The key required to access the model service.", "01f3e69a5a9b2c9b": "The key required to access the model service.",
"025b70bde54ed1ef": "Whether Cursor can select and use this model.",
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}", "028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit", "051836569928a9f9": "Edit",
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)", "05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
"0580e0a99a6f1afc": "Artifacts", "0580e0a99a6f1afc": "Artifacts",
"05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.",
"076832c1b2de22c3": "Cache write: {tokens}", "076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}", "07879e064ae16542": "Estimated output: {tokens}",
"07c657ed4747126e": "Anthropic extra parameters", "07ec86e0f1d44f91": "Created",
"08d742566ffc9fa3": "Clear all statistics?", "08d742566ffc9fa3": "Clear all statistics?",
"092b520558eff5f2": "Not tested",
"099008ea7a42ebd1": "All custom Header values must be strings", "099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value", "09ebc2643631ba25": "Estimated value",
"0a5f9a892960a00a": "Updated",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}", "0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0bd20614caf56647": "Select or enter multiple models. When adding in bulk, each display name defaults to its model name.",
"0c2fee621d406ea7": "Select an existing provider or create a new one.",
"0c70665b6eb65f1a": "No", "0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output", "0c72229b7db0e1a9": "Model output",
"0d5e2bdb15579fc4": "Messages", "0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management", "0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
"0ec1e85b0c3cfa65": "Call details", "0ec1e85b0c3cfa65": "Call details",
"105a9082c346f958": "Testing…", "105a9082c346f958": "Testing…",
"124be3f86f197802": "Token usage", "124be3f86f197802": "Token usage",
@@ -29,10 +31,12 @@
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.", "13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate", "13b61c5f697b6700": "Cache hit rate",
"146da2e2a991493e": "Fetching…", "146da2e2a991493e": "Fetching…",
"15730c19fd7eef51": "Request protocol", "15d76f1c211cc140": "Leave blank to keep the current API Key.",
"168e845a86bc3703": "Add model", "168e845a86bc3703": "Add model",
"16aa4ae1427fccce": "Custom Headers JSON",
"16d0d7e2b332af72": "Total calls: {count}", "16d0d7e2b332af72": "Total calls: {count}",
"1813d362a82fd437": "Maximize window", "1813d362a82fd437": "Maximize window",
"18d25065b3f7db39": "The full request URL is required",
"19658d9fa9aa8de4": "Installing…", "19658d9fa9aa8de4": "Installing…",
"1a3f0617d6de8e52": "Username", "1a3f0617d6de8e52": "Username",
"1ae6b0a0f8266382": "Close window", "1ae6b0a0f8266382": "Close window",
@@ -41,50 +45,41 @@
"1c6926877b1bbfb2": "Total requests: {tokens}", "1c6926877b1bbfb2": "Total requests: {tokens}",
"1cb7e646ac883fb2": "Proxy port", "1cb7e646ac883fb2": "Proxy port",
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)", "1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
"1d27f02ed278ebc9": "Configuration file", "1cef73a53d7ad0f2": "For example: OpenAI",
"1f9b46e61efcccb5": "Filter: {label}, {summary}", "1f9b46e61efcccb5": "Filter: {label}, {summary}",
"202064bb84804852": "Leave blank to use the default.", "21296ab18ad9af25": "Extra parameters JSON",
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language", "217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved", "22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23e49479e15e6770": "Version {version} is available", "23e49479e15e6770": "Version {version} is available",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
"29585d7193539200": "Current version {version}", "29585d7193539200": "Current version {version}",
"29fbbef32a6eb58b": "Do not show this ad again", "29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.", "2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2caeaec539e78898": "Thinking budget tokens",
"2cd0f3be8738a86c": "Cancel", "2cd0f3be8738a86c": "Cancel",
"2d30c2a98ebb5278": "Current: {rate}", "2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records", "2eb2bf7c6597ab9a": "Detailed records",
"2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.", "2f1b67cdaa23351a": "Custom full request URL",
"2f4a361f878176d1": "{label} must be valid JSON", "2f4a361f878176d1": "{label} must be valid JSON",
"2f4a9609285d8f49": "TAB settings saved", "2f4a9609285d8f49": "TAB settings saved",
"2f5f1d6fbfb061ed": "Not set", "2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL", "2f6416a2c424856b": "Final request URL",
"2f7ba5fd1d12f7f9": "Open the tutorial?",
"2f7dec3be28d7597": "{count} selected", "2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete", "2f9daa828907b93f": "Delete",
"346ff60e6c7c5181": "Reading…",
"36f33adaf0942634": "Confirm", "36f33adaf0942634": "Confirm",
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
"378bb0eec39fa8a2": "Last page", "378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"393df9bb13ea4900": "Hit",
"393e1241552b1870": "Request", "393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing", "398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input", "39f52eee100131d7": "Cached input",
"3a0fb74abe2460b6": "Chunks", "3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Clear storage", "3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all", "3a5040b68abf75f9": "Select all",
"3a8c76b2ce785f96": "Review and import", "3bd5b3f538e3270a": "New provider",
"3b67824289b5fa1e": "Dock icon hidden",
"3c94b4c75940c178": "Input Tokens", "3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}", "3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language", "3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended", "3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.", "3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close", "3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light", "4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)", "42655ed8e4108ae2": "Input (non-cached)",
@@ -93,18 +88,13 @@
"43cb41d62de2d179": "Proxy requires authentication", "43cb41d62de2d179": "Proxy requires authentication",
"461d6a57900c2ed7": "Connectivity test failed: {error}", "461d6a57900c2ed7": "Connectivity test failed: {error}",
"470049252e54de6a": "Success rate: {rate}", "470049252e54de6a": "Success rate: {rate}",
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
"48b970b568a7f8f9": "Proxy settings", "48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total", "48d8db17bae06246": "{count} total",
"492042ed1fdc29ed": "Version {version} is ready to install", "492042ed1fdc29ed": "Version {version} is ready to install",
"4927a53bcc886afb": "Loading…", "4927a53bcc886afb": "Loading…",
"493e097fac6bd217": "Providers",
"497c85690c4cc0fc": "No data", "497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a8d6841b4023edf": "Confirm import",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread", "4d0680f9efaef147": "Unread",
"4e30d7c9ed2b0eee": "Not set",
"4eafa9e925b30bcd": "Custom", "4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day", "51d04bc3d286f018": "Last calendar day",
"51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models",
@@ -112,19 +102,15 @@
"5401344227e49e2f": "TAB settings", "5401344227e49e2f": "TAB settings",
"54644705e9c61009": "Port settings", "54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA", "54c53e5fe791d1f3": "Initialize CA",
"54e6745ff43c9c74": "Unable to save model order",
"550eddc3c7fefa99": "Sponsored", "550eddc3c7fefa99": "Sponsored",
"555737734a6371e6": "Delete provider",
"56432ba297009bdc": "Initialize the CA first", "56432ba297009bdc": "Initialize the CA first",
"56627c94a9decee6": "Maximum output tokens",
"576d81bb0631b165": "Import",
"5886afc1c71df1fe": "Shown in the Cursor model description.",
"59346e82b3dd2998": "TAB service address", "59346e82b3dd2998": "TAB service address",
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service", "5a3bd99fa69a40c1": "Use public service",
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5b17f59d33bde39e": "Error: {error}", "5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All", "5c55a67935af8f45": "All",
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.", "5cca0b7972a11f3a": "Custom context must be an integer greater than 0",
"5d59857bf039cac9": "Cursor Assistant v{version}", "5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled", "5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?", "5f9acfb945229062": "Are you sure you no longer want to see this ad?",
@@ -135,7 +121,6 @@
"621f63a5f08384ac": "Cache read: {tokens}", "621f63a5f08384ac": "Cache read: {tokens}",
"6320b4a8722a851f": "Status", "6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply", "63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Duplicate",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard", "6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates", "651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}", "652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
@@ -143,8 +128,7 @@
"656ab25e264cc4e4": "No models are available to Cursor yet", "656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools", "65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}", "65cb9a7b4f620b6b": "Prompt {tokens}",
"680680288a6d2ad2": "Show in Dock", "68152165b3348852": "Select the request protocol used by this provider.",
"68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings", "68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time", "6a9906c79f26c0ba": "Start time",
"6aa8f49cc992dfd7": "Test", "6aa8f49cc992dfd7": "Test",
@@ -152,103 +136,101 @@
"6d1876364ac6457d": "Proxy mode", "6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date", "6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}", "7005693f4f050bce": "Cache I/O {cost}",
"72644ec4389da2f7": "Default layout", "71eb2c6fce991a29": "Add provider",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.", "722e156c20650a3f": "Use macOS or Windows.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.", "7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"75844712d53a24de": "Values must be strings; when editing, null keeps the original value of the corresponding sensitive Header.",
"788db1cfec2a3db5": "Theme", "788db1cfec2a3db5": "Theme",
"7995087e5a3dfe66": "Restore window", "7995087e5a3dfe66": "Restore window",
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA", "7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
"7a3cec4ca715de80": "Call statistics", "7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear", "7ba2d6728fe2531b": "Confirm clear",
"7cea2f3c46565d29": "OpenAI extra parameters",
"7d9f043f8f7ab45c": "Version {version} is available in Settings", "7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required", "7e0891860c9e6374": "TAB service address is required",
"7e1845870b528392": "Enable model",
"7e1f06318e80c3af": "Statistics cleared", "7e1f06318e80c3af": "Statistics cleared",
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.", "7e9ab9ada2cbf2cb": "Name and Base URL are required",
"7f3c8312816fe26a": "Refreshing…", "7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates", "7f68ebad19ba6bcd": "Check for updates",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service", "811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83fcfb4c1f2c1641": "Fetch models", "83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login", "842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import", "84924374710e03bd": "Base URL must be a valid URL",
"864597982c308d72": "Silent start enabled",
"86b7355ec3bd55ef": "Hide API Key", "86b7355ec3bd55ef": "Hide API Key",
"8716e1344b0daddb": "Cursor official", "8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions", "878a8ab176429a86": "View instructions",
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.", "883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.",
"8a8542f6964852dc": "Next page", "8a8542f6964852dc": "Next page",
"8b6ff498515bcc2f": "Time", "8b6ff498515bcc2f": "Time",
"8ccaf87ddb9ca3f4": "Legacy configuration",
"8d0c47eb9eac2d34": "Call type", "8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)", "8df48894086d6fbd": "Reason (optional)",
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
"8ea973394446abba": "Cursor Configuration",
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.", "8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
"90800c48a1dd0655": "{label} must be a JSON object", "90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password", "919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview", "91aaf184cfc17ffd": "Overview",
"91ee087c3d4f3220": "Supports a complete HTTP(S) URL or a relative path beginning with /, combined with the provider URL.",
"940a168911ade998": "Items per page", "940a168911ade998": "Items per page",
"946b3ffc02f026c0": "Delete this model?", "946b3ffc02f026c0": "Delete this model?",
"94803f35c825e47a": "Full request URL",
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
"95f76d30c25d5eda": "CA installation is not supported on this system",
"966498853d801a52": "TAB connection", "966498853d801a52": "TAB connection",
"9850ed41a5bfbb0c": "{count} selected", "9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA", "997ec8201c2adeda": "Open terminal to install CA",
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?", "9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort", "9c41b3a9e12ac994": "Reasoning effort",
"9e356080c56877f8": "Silent start disabled", "9d8f2ef4e85ea665": "Custom context tokens",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago", "9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9f6fee1aba17a565": "Language", "9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week", "9fb48101d237ff96": "Last week",
"a026f37e613cf48b": "Output Tokens", "a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535", "a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a0c42c24e74f8380": "{name} Copy",
"a1a42cd9b16e2162": "Application", "a1a42cd9b16e2162": "Application",
"a1b8c98f29374a2f": "Silent start", "a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.",
"a3030bf8f16dc63c": "Save", "a3030bf8f16dc63c": "Save",
"a340bdf12a15fd80": "All {count} models in this configuration already exist; nothing needs to be imported",
"a363743025795ec7": "I've initialized it — refresh", "a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.", "a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average", "a49ffd73bc85333d": "Average",
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password", "a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test", "a693d69af48bfe48": "Save and test",
"a748cc074f78de00": "View details", "a748cc074f78de00": "View details",
"a7617f42f898b2bf": "Use complete request URL",
"a8036485f9227f2c": "Drag to reorder",
"a98585871c5313ff": "Display name", "a98585871c5313ff": "Display name",
"a9ab292ea9feecdc": "Provider",
"aa678e13bda90e3c": "Custom Header values must be strings or null",
"ab2f31f30acf7fda": "Protocol",
"ab9084a640fbb864": "Deselect all", "ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled", "abecab6701177721": "Launch at login enabled",
"ac58d0f9a3f8d389": "Enter model notes",
"ac69f68b7010ec79": "Download and install", "ac69f68b7010ec79": "Download and install",
"ad6a60ee93d3ba3e": "Loading call details…", "ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.",
"aee88743413144a2": "Refresh", "aee88743413144a2": "Refresh",
"b06325c5660f0c29": "Direct", "b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b4411558b932266f": "Provider type", "b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}", "b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes", "b5141d3d19e9a048": "Yes",
"b6725f218ebaef26": "Dock icon shown", "b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.",
"b75a46aad3e7c132": "Non-cached input: {tokens}", "b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics", "b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)", "b86967982067d295": " (estimated)",
"b89a0e4584f27ab5": "Open terminal", "b89a0e4584f27ab5": "Open terminal",
"b8c9b486c83b5778": "Hide ad", "b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route", "b9670c85a4ab939e": "Route",
"b97ad406809572e1": "Enable reasoning",
"b9af2de88d903be7": "Proxy address", "b9af2de88d903be7": "Proxy address",
"ba5865fbc734e672": "For example: Primary model",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed", "baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing", "bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark", "bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why", "bda62ce1d5e4ace9": "Tell us why",
"bf57afd709694b55": "Overview time range", "bf57afd709694b55": "Overview time range",
"bf84379b75735037": "Edit provider",
"bfc01caf9fe0c841": "Cache hit rate {rate}", "bfc01caf9fe0c841": "Cache hit rate {rate}",
"c1e98892a77f7a19": "{count} per page", "c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body", "c3760858cdb6d9f4": "Request body",
"c62a58459251b02c": "Image generation",
"c6cc835023617457": "Custom context",
"c7ea2c9bc43134bd": "Edit model", "c7ea2c9bc43134bd": "Edit model",
"c8c14507b2d37395": "Reasoning effort",
"c8df3c14a003bfcd": "Unable to load call details", "c8df3c14a003bfcd": "Unable to load call details",
"c98e118e0a43f078": "Model", "c98e118e0a43f078": "Model",
"c9b9ae7a61444ab7": "Previous page", "c9b9ae7a61444ab7": "Previous page",
@@ -257,25 +239,24 @@
"cea1aafe9416de7b": "Request headers", "cea1aafe9416de7b": "Request headers",
"cfae1a14d2120c57": "Detailed mode", "cfae1a14d2120c57": "Detailed mode",
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.", "cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
"d0bfccc77315d887": "Last month", "d0bfccc77315d887": "Last month",
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.", "d15a909c3490a7e0": "Endpoint type",
"d27db596b73a0a66": "For example: 272000",
"d2d648bd1c94b7f9": "Authentication", "d2d648bd1c94b7f9": "Authentication",
"d30d35c5ec4a888d": "Select or enter at least one model",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.", "d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…", "d3d21191f32e79a5": "Processing…",
"d58c88688e1a949d": "Presets", "d44e9b3d3b31d37b": "Name",
"d60669bb26a22f5d": "Leave blank to use the default", "d667c1616e31c4c4": "Leave blank to keep the current key",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d86fa42c3848c680": "Use system proxy", "d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu", "d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate", "da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour", "da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes", "db0a1393f05aa4bd": "Inherits the provider by default; override it for this model if needed.",
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests", "ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.", "dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df1baa9f706d970b": "To add",
"df3d58c7d84b85f2": "Settings", "df3d58c7d84b85f2": "Settings",
"df8b71c74d9b8478": "Response stream", "df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled", "dfb802238b38fbd4": "Enabled",
@@ -283,29 +264,32 @@
"e0fae77446a389a3": "Speed: {speed} tokens/s", "e0fae77446a389a3": "Speed: {speed} tokens/s",
"e1295adecbb77755": "Close ad", "e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year", "e14115de7f7c5795": "Token usage over the past year",
"e14a5eee9b0b0f9f": "Whether the model declares reasoning support.",
"e14f20d572c02611": "Provider call sequence", "e14f20d572c02611": "Provider call sequence",
"e17a5b9c90cda6ab": "Model duplicated",
"e18516550b9a5105": "No usage", "e18516550b9a5105": "No usage",
"e24ebe4a866d69bf": "Test failed: {error}", "e24ebe4a866d69bf": "Test failed: {error}",
"e25bf3f419bb68f0": "Call history", "e25bf3f419bb68f0": "Call history",
"e3fee05f688708b4": "LLM calls", "e3fee05f688708b4": "LLM calls",
"e4760c6a1df24f17": "Complete request URL",
"e5043c7a2b408271": "Last 10 minutes", "e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page", "e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.", "e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e671f8c7598139ef": "After entering a token count, it is added as an extra option to the Cursor model Context list. It only takes effect when selected in Cursor.",
"e6ca887f22288cde": "Model ID and display name are required",
"e77e3d58b0dcffaa": "Duration", "e77e3d58b0dcffaa": "Duration",
"e825a2a42c22380e": "Model type",
"e828bd3a0151edc2": "The local CA must be trusted by the system", "e828bd3a0151edc2": "The local CA must be trusted by the system",
"e8b1268c1e3610f2": "Existing",
"ea26b760e930a7ca": "Call observability", "ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "Provider URL", "eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.", "eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.", "eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"ed31fbb483ee1b0a": "Actions", "ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA", "edc70de18c6da1a6": "Install local CA",
"ede6d08f397df283": "Cursor settings",
"ee239f3943293f87": "Sunday", "ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f04c91a6bc3a6926": "Extra parameters",
"f2bdc88464c51c2e": "Show API Key", "f2bdc88464c51c2e": "Show API Key",
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
"f3a76d896853c1df": "Miss",
"f4694c46b1e19602": "Final request type", "f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}", "f4dcb6a3ceb32247": "Page {page} of {count}",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year", "f4fa9f31ea2ae58d": "Token usage calendar for the past year",
+58 -74
View File
@@ -1,25 +1,27 @@
{ {
"0006d696d8e1ec28": "新增",
"00929f23850e4ff0": "成功调用:{count}", "00929f23850e4ff0": "成功调用:{count}",
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。", "01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
"025b70bde54ed1ef": "模型是否允许被 Cursor 选择使用。",
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}", "028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}", "03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "编辑", "051836569928a9f9": "编辑",
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)", "05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
"0580e0a99a6f1afc": "工件数", "0580e0a99a6f1afc": "工件数",
"05912a17829faacc": "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
"076832c1b2de22c3": "缓存写入:{tokens}", "076832c1b2de22c3": "缓存写入:{tokens}",
"07879e064ae16542": "输出推算:{tokens}", "07879e064ae16542": "输出推算:{tokens}",
"07c657ed4747126e": "Anthropic 额外参数", "07ec86e0f1d44f91": "创建时间",
"08d742566ffc9fa3": "确定要清理所有统计数据吗?", "08d742566ffc9fa3": "确定要清理所有统计数据吗?",
"092b520558eff5f2": "未测试",
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串", "099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
"09ebc2643631ba25": "价值估算", "09ebc2643631ba25": "价值估算",
"0a5f9a892960a00a": "更新时间",
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}", "0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
"0bd20614caf56647": "支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。",
"0c2fee621d406ea7": "选择已有上游,或创建一个新的上游。",
"0c70665b6eb65f1a": "否", "0c70665b6eb65f1a": "否",
"0c72229b7db0e1a9": "模型输出", "0c72229b7db0e1a9": "模型输出",
"0d5e2bdb15579fc4": "消息数", "0d5e2bdb15579fc4": "消息数",
"0e41f8e3d59ec47b": "存储管理", "0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
"0ec1e85b0c3cfa65": "调用详情", "0ec1e85b0c3cfa65": "调用详情",
"105a9082c346f958": "测试中…", "105a9082c346f958": "测试中…",
"124be3f86f197802": "Token 消耗", "124be3f86f197802": "Token 消耗",
@@ -29,10 +31,12 @@
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。", "13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率", "13b61c5f697b6700": "缓存命中率",
"146da2e2a991493e": "获取中…", "146da2e2a991493e": "获取中…",
"15730c19fd7eef51": "请求协议", "15d76f1c211cc140": "留空表示保留当前 API Key。",
"168e845a86bc3703": "添加模型", "168e845a86bc3703": "添加模型",
"16aa4ae1427fccce": "自定义 Headers JSON",
"16d0d7e2b332af72": "总调用:{count}", "16d0d7e2b332af72": "总调用:{count}",
"1813d362a82fd437": "最大化窗口", "1813d362a82fd437": "最大化窗口",
"18d25065b3f7db39": "请求完整地址不能为空",
"19658d9fa9aa8de4": "安装中…", "19658d9fa9aa8de4": "安装中…",
"1a3f0617d6de8e52": "用户名", "1a3f0617d6de8e52": "用户名",
"1ae6b0a0f8266382": "关闭窗口", "1ae6b0a0f8266382": "关闭窗口",
@@ -41,50 +45,41 @@
"1c6926877b1bbfb2": "总请求:{tokens}", "1c6926877b1bbfb2": "总请求:{tokens}",
"1cb7e646ac883fb2": "代理端口", "1cb7e646ac883fb2": "代理端口",
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)", "1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
"1d27f02ed278ebc9": "配置文件", "1cef73a53d7ad0f2": "例如:OpenAI",
"1f9b46e61efcccb5": "筛选项:{label},{summary}", "1f9b46e61efcccb5": "筛选项:{label},{summary}",
"202064bb84804852": "留空时使用默认值。", "21296ab18ad9af25": "额外参数 JSON",
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统", "217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存", "22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23e49479e15e6770": "发现新版本 {version}", "23e49479e15e6770": "发现新版本 {version}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
"29585d7193539200": "当前版本 {version}", "29585d7193539200": "当前版本 {version}",
"29fbbef32a6eb58b": "不再显示此广告", "29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。", "2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2caeaec539e78898": "思考预算 Token",
"2cd0f3be8738a86c": "取消", "2cd0f3be8738a86c": "取消",
"2d30c2a98ebb5278": "当前:{rate}", "2d30c2a98ebb5278": "当前:{rate}",
"2eb2bf7c6597ab9a": "详细记录", "2eb2bf7c6597ab9a": "详细记录",
"2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。", "2f1b67cdaa23351a": "自定义请求完整地址",
"2f4a361f878176d1": "{label} 必须是有效 JSON", "2f4a361f878176d1": "{label} 必须是有效 JSON",
"2f4a9609285d8f49": "TAB 设置已保存", "2f4a9609285d8f49": "TAB 设置已保存",
"2f5f1d6fbfb061ed": "未设置", "2f5f1d6fbfb061ed": "未设置",
"2f6416a2c424856b": "最终请求地址", "2f6416a2c424856b": "最终请求地址",
"2f7ba5fd1d12f7f9": "打开使用教程?",
"2f7dec3be28d7597": "已选择 {count} 个", "2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除", "2f9daa828907b93f": "删除",
"346ff60e6c7c5181": "读取中…",
"36f33adaf0942634": "确认", "36f33adaf0942634": "确认",
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页", "378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"393df9bb13ea4900": "命中",
"393e1241552b1870": "请求", "393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入", "398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入", "39f52eee100131d7": "缓存输入",
"3a0fb74abe2460b6": "分块数", "3a0fb74abe2460b6": "分块数",
"3a3f595df70ec8ff": "清理存储空间", "3a3f595df70ec8ff": "清理存储空间",
"3a5040b68abf75f9": "全选", "3a5040b68abf75f9": "全选",
"3a8c76b2ce785f96": "查看并导入", "3bd5b3f538e3270a": "新建上游",
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
"3c94b4c75940c178": "输入 Token", "3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}", "3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言", "3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容", "3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。", "3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭", "3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色", "4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)", "42655ed8e4108ae2": "输入(非缓存)",
@@ -93,18 +88,13 @@
"43cb41d62de2d179": "代理需要认证", "43cb41d62de2d179": "代理需要认证",
"461d6a57900c2ed7": "连通性测试失败:{error}", "461d6a57900c2ed7": "连通性测试失败:{error}",
"470049252e54de6a": "成功占比:{rate}", "470049252e54de6a": "成功占比:{rate}",
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
"48b970b568a7f8f9": "代理设置", "48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条", "48d8db17bae06246": "共 {count} 条",
"492042ed1fdc29ed": "版本 {version} 可以安装", "492042ed1fdc29ed": "版本 {version} 可以安装",
"4927a53bcc886afb": "加载中…", "4927a53bcc886afb": "加载中…",
"493e097fac6bd217": "上游配置",
"497c85690c4cc0fc": "暂无数据", "497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a8d6841b4023edf": "确认导入",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读", "4d0680f9efaef147": "未读",
"4e30d7c9ed2b0eee": "不设置",
"4eafa9e925b30bcd": "自定义", "4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日", "51d04bc3d286f018": "近1自然日",
"51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功",
@@ -112,19 +102,15 @@
"5401344227e49e2f": "TAB 设置", "5401344227e49e2f": "TAB 设置",
"54644705e9c61009": "端口设置", "54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA", "54c53e5fe791d1f3": "初始化 CA",
"54e6745ff43c9c74": "排序失败",
"550eddc3c7fefa99": "推广", "550eddc3c7fefa99": "推广",
"555737734a6371e6": "删除上游",
"56432ba297009bdc": "请先初始化 CA", "56432ba297009bdc": "请先初始化 CA",
"56627c94a9decee6": "最大输出 Token",
"576d81bb0631b165": "导入",
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
"59346e82b3dd2998": "TAB 服务地址", "59346e82b3dd2998": "TAB 服务地址",
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务", "5a3bd99fa69a40c1": "使用公益服务",
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5b17f59d33bde39e": "错误:{error}", "5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部", "5c55a67935af8f45": "全部",
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。", "5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数",
"5d59857bf039cac9": "Cursor 助手 v{version}", "5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动", "5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?", "5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -135,7 +121,6 @@
"621f63a5f08384ac": "缓存读取:{tokens}", "621f63a5f08384ac": "缓存读取:{tokens}",
"6320b4a8722a851f": "状态", "6320b4a8722a851f": "状态",
"63c73c4730f4473e": "应用", "63c73c4730f4473e": "应用",
"63d90d977348ab1f": "复制",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板", "6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新", "651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", "652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
@@ -143,8 +128,7 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数", "65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}", "65cb9a7b4f620b6b": "提示词 {tokens}",
"680680288a6d2ad2": "在 Dock 栏显示", "68152165b3348852": "选择上游服务使用的请求协议。",
"68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置", "68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间", "6a9906c79f26c0ba": "开始时间",
"6aa8f49cc992dfd7": "测试", "6aa8f49cc992dfd7": "测试",
@@ -152,103 +136,101 @@
"6d1876364ac6457d": "代理方式", "6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本", "6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}", "7005693f4f050bce": "缓存读写 {cost}",
"72644ec4389da2f7": "默认平铺", "71eb2c6fce991a29": "添加上游",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试", "722e156c20650a3f": "请使用 macOS 或 Windows。",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。", "7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"75844712d53a24de": "值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。",
"788db1cfec2a3db5": "主题", "788db1cfec2a3db5": "主题",
"7995087e5a3dfe66": "还原窗口", "7995087e5a3dfe66": "还原窗口",
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA", "7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
"7a3cec4ca715de80": "调用统计", "7a3cec4ca715de80": "调用统计",
"7ba2d6728fe2531b": "确认清理", "7ba2d6728fe2531b": "确认清理",
"7cea2f3c46565d29": "OpenAI 额外参数",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装", "7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空", "7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1845870b528392": "启用模型",
"7e1f06318e80c3af": "统计数据已清理", "7e1f06318e80c3af": "统计数据已清理",
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。", "7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空",
"7f3c8312816fe26a": "刷新中…", "7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新", "7f68ebad19ba6bcd": "检查更新",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务", "811a3b22a5a7f2d5": "无法连接本地管理服务",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83fcfb4c1f2c1641": "获取模型", "83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动", "842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置", "84924374710e03bd": "Base URL 必须是有效地址",
"864597982c308d72": "已开启静默启动",
"86b7355ec3bd55ef": "隐藏 API Key", "86b7355ec3bd55ef": "隐藏 API Key",
"8716e1344b0daddb": "Cursor 官方", "8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明", "878a8ab176429a86": "查看说明",
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。", "883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。",
"8a8542f6964852dc": "下一页", "8a8542f6964852dc": "下一页",
"8b6ff498515bcc2f": "时间", "8b6ff498515bcc2f": "时间",
"8ccaf87ddb9ca3f4": "旧版配置",
"8d0c47eb9eac2d34": "调用类型", "8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)", "8df48894086d6fbd": "原因(可选)",
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
"8ea973394446abba": "Cursor 配置",
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。", "8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
"90800c48a1dd0655": "{label} 必须是 JSON 对象", "90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码", "919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览", "91aaf184cfc17ffd": "数据概览",
"91ee087c3d4f3220": "支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。",
"940a168911ade998": "每页条数", "940a168911ade998": "每页条数",
"946b3ffc02f026c0": "确定删除这个模型吗?", "946b3ffc02f026c0": "确定删除这个模型吗?",
"94803f35c825e47a": "请求完整地址",
"94df1e7f04815daf": "仅用于界面展示,不会改变发送给上游的模型名称。",
"95f76d30c25d5eda": "当前系统暂不支持安装 CA",
"966498853d801a52": "TAB 选择", "966498853d801a52": "TAB 选择",
"9850ed41a5bfbb0c": "已选 {count} 项", "9850ed41a5bfbb0c": "已选 {count} 项",
"997ec8201c2adeda": "打开终端安装 CA", "997ec8201c2adeda": "打开终端安装 CA",
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?", "9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度", "9c41b3a9e12ac994": "思考强度",
"9e356080c56877f8": "已关闭静默启动", "9d8f2ef4e85ea665": "自定义上下文 tokens",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前", "9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9f6fee1aba17a565": "语言", "9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周", "9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token", "a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数", "a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a0c42c24e74f8380": "{name} 副本",
"a1a42cd9b16e2162": "应用设置", "a1a42cd9b16e2162": "应用设置",
"a1b8c98f29374a2f": "静默启动", "a2901cd2743c1921": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。",
"a3030bf8f16dc63c": "保存", "a3030bf8f16dc63c": "保存",
"a340bdf12a15fd80": "配置中的 {count} 个模型均已存在,无需重复导入",
"a363743025795ec7": "我已初始化,刷新", "a363743025795ec7": "我已初始化,刷新",
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。", "a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均", "a49ffd73bc85333d": "平均",
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码", "a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试", "a693d69af48bfe48": "保存并测试",
"a748cc074f78de00": "查看详情", "a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a98585871c5313ff": "显示名称", "a98585871c5313ff": "显示名称",
"a9ab292ea9feecdc": "上游",
"aa678e13bda90e3c": "自定义 Headers 的值必须是字符串或 null",
"ab2f31f30acf7fda": "协议",
"ab9084a640fbb864": "全不选", "ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动", "abecab6701177721": "已开启开机启动",
"ac58d0f9a3f8d389": "请输入模型备注",
"ac69f68b7010ec79": "下载并安装", "ac69f68b7010ec79": "下载并安装",
"ad6a60ee93d3ba3e": "正在加载调用详情…", "ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"aee88743413144a2": "刷新", "aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连", "b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b4411558b932266f": "上游类型", "b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}", "b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是", "b5141d3d19e9a048": "是",
"b6725f218ebaef26": "已显示 Dock 栏图标", "b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。",
"b75a46aad3e7c132": "非缓存输入:{tokens}", "b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据", "b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)", "b86967982067d295": "(估算)",
"b89a0e4584f27ab5": "打开终端", "b89a0e4584f27ab5": "打开终端",
"b8c9b486c83b5778": "不再显示广告", "b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由", "b9670c85a4ab939e": "路由",
"b97ad406809572e1": "启用推理",
"b9af2de88d903be7": "代理地址", "b9af2de88d903be7": "代理地址",
"ba5865fbc734e672": "例如:主力模型",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}", "baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪", "bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色", "bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因", "bda62ce1d5e4ace9": "可以告诉我们原因",
"bf57afd709694b55": "概览时间范围", "bf57afd709694b55": "概览时间范围",
"bf84379b75735037": "编辑上游",
"bfc01caf9fe0c841": "缓存命中率 {rate}", "bfc01caf9fe0c841": "缓存命中率 {rate}",
"c1e98892a77f7a19": "{count} 条/页", "c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体", "c3760858cdb6d9f4": "请求体",
"c62a58459251b02c": "图片生成",
"c6cc835023617457": "自定义上下文",
"c7ea2c9bc43134bd": "编辑模型", "c7ea2c9bc43134bd": "编辑模型",
"c8c14507b2d37395": "推理强度",
"c8df3c14a003bfcd": "无法加载调用详情", "c8df3c14a003bfcd": "无法加载调用详情",
"c98e118e0a43f078": "模型", "c98e118e0a43f078": "模型",
"c9b9ae7a61444ab7": "上一页", "c9b9ae7a61444ab7": "上一页",
@@ -257,25 +239,24 @@
"cea1aafe9416de7b": "请求头", "cea1aafe9416de7b": "请求头",
"cfae1a14d2120c57": "详细模式", "cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。", "cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月", "d0bfccc77315d887": "近一个月",
"d1251cd752d4ec25": "留空时使用 adaptive thinking。", "d15a909c3490a7e0": "端点类型",
"d27db596b73a0a66": "例如:272000",
"d2d648bd1c94b7f9": "认证", "d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…", "d3d21191f32e79a5": "处理中…",
"d58c88688e1a949d": "常用预设", "d44e9b3d3b31d37b": "名称",
"d60669bb26a22f5d": "留空使用默认值", "d667c1616e31c4c4": "留空以保留当前密钥",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理", "d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单", "d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书", "da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时", "da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注", "db0a1393f05aa4bd": "默认继承上游,可为当前模型单独修改。",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求", "ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。", "dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置", "df3d58c7d84b85f2": "设置",
"df8b71c74d9b8478": "响应流", "df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用", "dfb802238b38fbd4": "已启用",
@@ -283,29 +264,32 @@
"e0fae77446a389a3": "速度:{speed} tokens/s", "e0fae77446a389a3": "速度:{speed} tokens/s",
"e1295adecbb77755": "关闭广告", "e1295adecbb77755": "关闭广告",
"e14115de7f7c5795": "过去一年的 Token 用量", "e14115de7f7c5795": "过去一年的 Token 用量",
"e14a5eee9b0b0f9f": "是否声明模型支持推理能力。",
"e14f20d572c02611": "上游调用序号", "e14f20d572c02611": "上游调用序号",
"e17a5b9c90cda6ab": "模型已复制",
"e18516550b9a5105": "无用量", "e18516550b9a5105": "无用量",
"e24ebe4a866d69bf": "测试失败:{error}", "e24ebe4a866d69bf": "测试失败:{error}",
"e25bf3f419bb68f0": "调用详细", "e25bf3f419bb68f0": "调用详细",
"e3fee05f688708b4": "LLM 调用", "e3fee05f688708b4": "LLM 调用",
"e4760c6a1df24f17": "完整请求 URL",
"e5043c7a2b408271": "近10分钟", "e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页", "e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效", "e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
"e77e3d58b0dcffaa": "耗时", "e77e3d58b0dcffaa": "耗时",
"e825a2a42c22380e": "模型类型",
"e828bd3a0151edc2": "需要在系统中信任本地 CA", "e828bd3a0151edc2": "需要在系统中信任本地 CA",
"e8b1268c1e3610f2": "已存在",
"ea26b760e930a7ca": "调用观测", "ea26b760e930a7ca": "调用观测",
"eb11e2df1d8ae387": "上游地址", "eb11e2df1d8ae387": "上游地址",
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。", "eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
"ed31fbb483ee1b0a": "操作", "ed31fbb483ee1b0a": "操作",
"edc70de18c6da1a6": "安装本地 CA", "edc70de18c6da1a6": "安装本地 CA",
"ede6d08f397df283": "Cursor 设置",
"ee239f3943293f87": "周日", "ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f04c91a6bc3a6926": "额外参数",
"f2bdc88464c51c2e": "显示 API Key", "f2bdc88464c51c2e": "显示 API Key",
"f396118b8afd2a21": "Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。",
"f3a76d896853c1df": "未命中",
"f4694c46b1e19602": "最终请求类型", "f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
+10 -1
View File
@@ -1,5 +1,4 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { desktopPlatform } from "../native/platform";
import styles from "./AppFrame.module.scss"; import styles from "./AppFrame.module.scss";
import { AppHeader } from "./AppHeader"; import { AppHeader } from "./AppHeader";
@@ -18,3 +17,13 @@ export function AppFrame() {
</div> </div>
); );
} }
export type DesktopPlatform = "macos" | "windows" | "linux";
function desktopPlatform(): DesktopPlatform {
// return "windows";
const agent = navigator.userAgent;
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
if (/Windows/.test(agent)) return "windows";
return "linux";
}
+1 -4
View File
@@ -1,9 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import appIcon from "../../src-tauri/icons/32x32.png"; import appIcon from "../../src-tauri/icons/32x32.png";
import { currentAppVersion } from "../native/appLifecycle"; import { currentAppVersion } from "../native/appLifecycle";
import type { DesktopPlatform } from "../native/platform"; import type { DesktopPlatform } from "./AppFrame";
import { WindowControls } from "./WindowControls"; import { WindowControls } from "./WindowControls";
import { MacTrafficLights } from "./MacTrafficLights";
import styles from "./AppHeader.module.scss"; import styles from "./AppHeader.module.scss";
type AppHeaderProps = { type AppHeaderProps = {
@@ -12,7 +11,6 @@ type AppHeaderProps = {
}; };
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
const showMacTrafficLights = !nativeDesktop && platform === "macos";
const showNativeUi = nativeDesktop && platform !== "macos"; const showNativeUi = nativeDesktop && platform !== "macos";
const [version, setVersion] = useState("…"); const [version, setVersion] = useState("…");
@@ -27,7 +25,6 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
return <header className={styles.root}> return <header className={styles.root}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" /> <div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div className={styles.uiLayer}> <div className={styles.uiLayer}>
{showMacTrafficLights && <MacTrafficLights />}
{showNativeUi && <> {showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK"> <div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" /> <img src={appIcon} alt="" />
@@ -156,14 +156,11 @@
.actions { .actions {
height: var(--app-toolbar-height); height: var(--app-toolbar-height);
min-width: var(--app-toolbar-height);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
padding: 6px 6px; padding: 2px 2px;
gap: 3px; gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
} }
.pageActions { .pageActions {
+20 -34
View File
@@ -9,11 +9,11 @@ import { FloatingAd } from "../components/ads/FloatingAd";
import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types"; import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types";
import { PageLayout } from "../components/layout/PageLayout"; import { PageLayout } from "../components/layout/PageLayout";
import { Card } from "../components/ui/Card"; import { Card } from "../components/ui/Card";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import controls from "../components/ui/Controls.module.scss"; import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon"; import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger"; import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons"; import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message"; import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList"; import { VirtualList } from "../components/virtual/VirtualList";
import { useI18n } from "../i18n/store"; import { useI18n } from "../i18n/store";
@@ -27,7 +27,7 @@ type MenuItem =
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string } | { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string }; | { kind: "group"; label: string };
const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"]; const keptAlivePages = ["/", "/calls", "/providers", "/settings", "/harness/cursor"];
const readAdStorageKey = "cursor-byok:read-ad-ids"; const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids"; const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
const tutorialReadStorageKey = "cursor-byok:tutorial-read"; const tutorialReadStorageKey = "cursor-byok:tutorial-read";
@@ -54,7 +54,6 @@ export function AppLayout() {
const [activeAd, setActiveAd] = useState<AdSlot | null>(null); const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null); const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState(""); const [dismissReason, setDismissReason] = useState("");
const [confirmTutorial, setConfirmTutorial] = useState(false);
const [tutorialRead, setTutorialRead] = useState(() => { const [tutorialRead, setTutorialRead] = useState(() => {
try { try {
return localStorage.getItem(tutorialReadStorageKey) === "true"; return localStorage.getItem(tutorialReadStorageKey) === "true";
@@ -72,23 +71,21 @@ export function AppLayout() {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon }, { kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon }, { kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
{ kind: "group", label: "Harness" }, { kind: "page", path: "/providers", label: t("上游配置"), icon: flatColorOrganizationIcon },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon }, { kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
{ kind: "group", label: "Harness" },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 设置"), icon: cursorIconUrl },
]; ];
const openTutorial = useCallback(() => { const openTutorial = useCallback(() => {
setConfirmTutorial(false); setTutorialRead(true);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
void api.openExternalUrl(tutorialUrl) void api.openExternalUrl(tutorialUrl)
.then(() => {
setTutorialRead(true);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
})
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause))); .catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]); }, [message]);
@@ -198,7 +195,7 @@ export function AppLayout() {
<button <button
type="button" type="button"
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`} aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
onClick={() => setConfirmTutorial(true)} onClick={openTutorial}
> >
{typeof item.icon === "string" {typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" /> ? <Icon src={item.icon} size="1.3em" />
@@ -221,25 +218,14 @@ export function AppLayout() {
</nav> </nav>
</Card> </Card>
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />} {activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
<ConfirmDialog <Modal
id="open-tutorial-dialog"
open={confirmTutorial}
title={t("打开使用教程?")}
cancelLabel={t("取消")}
confirmLabel={t("打开教程")}
onCancel={() => setConfirmTutorial(false)}
onConfirm={openTutorial}
>
<p>{t("将在系统浏览器中打开使用教程,是否继续?")}</p>
</ConfirmDialog>
<ConfirmDialog
id="dismiss-ad-dialog" id="dismiss-ad-dialog"
open={dismissCandidate !== null} open={dismissCandidate !== null}
title={t("不再显示此广告")} title={t("不再显示此广告")}
cancelLabel={t("取消")} closeLabel={t("取消")}
confirmLabel={t("确认")} submitLabel={t("确认")}
onCancel={closeDismissAd} onClose={closeDismissAd}
onConfirm={() => void dismissAd()} onSubmit={() => void dismissAd()}
> >
<p>{t("你确认不想再看到此广告吗?")}</p> <p>{t("你确认不想再看到此广告吗?")}</p>
<label className={styles.dismissReason}> <label className={styles.dismissReason}>
@@ -252,7 +238,7 @@ export function AppLayout() {
onChange={(event) => setDismissReason(event.target.value)} onChange={(event) => setDismissReason(event.target.value)}
/> />
</label> </label>
</ConfirmDialog> </Modal>
<main className={styles.content}> <main className={styles.content}>
<div className={styles.actionRegion}> <div className={styles.actionRegion}>
<Card className={styles.actions}> <Card className={styles.actions}>
@@ -1,30 +0,0 @@
.root {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
padding-left: 4px;
pointer-events: none;
}
.light {
width: 12px;
height: 12px;
flex: 0 0 12px;
border: 0.5px solid rgb(0 0 0 / 18%);
border-radius: 50%;
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
}
.close {
background: #ff5f57;
}
.minimize {
background: #febc2e;
}
.zoom {
background: #28c840;
}
@@ -1,9 +0,0 @@
import styles from "./MacTrafficLights.module.scss";
export function MacTrafficLights() {
return <div className={styles.root} aria-hidden="true">
<span className={[styles.light, styles.close].join(" ")} />
<span className={[styles.light, styles.minimize].join(" ")} />
<span className={[styles.light, styles.zoom].join(" ")} />
</div>;
}
+1 -27
View File
@@ -1,19 +1,13 @@
import { getVersion, setDockVisibility } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater"; import { check, type Update } from "@tauri-apps/plugin-updater";
import { api, type DesktopSettings } from "../api";
import { desktopPlatform } from "./platform";
export function hasNativeAppLifecycle(): boolean { export function hasNativeAppLifecycle(): boolean {
return isTauri(); return isTauri();
} }
export function hasDockVisibilitySetting(): boolean {
return hasNativeAppLifecycle() && desktopPlatform() === "macos";
}
export async function currentAppVersion(): Promise<string> { export async function currentAppVersion(): Promise<string> {
return hasNativeAppLifecycle() ? getVersion() : "dev"; return hasNativeAppLifecycle() ? getVersion() : "dev";
} }
@@ -26,26 +20,6 @@ export async function writeAutostart(enabled: boolean): Promise<void> {
await (enabled ? enable() : disable()); await (enabled ? enable() : disable());
} }
export async function readDesktopSettings(): Promise<DesktopSettings> {
return api.desktopSettings();
}
export async function writeSilentStart(silentStart: boolean): Promise<void> {
const settings = await readDesktopSettings();
await api.setDesktopSettings({ ...settings, silent_start: silentStart });
}
export async function writeDockIconVisibility(visible: boolean): Promise<void> {
const settings = await readDesktopSettings();
await setDockVisibility(visible);
try {
await api.setDesktopSettings({ ...settings, show_dock_icon: visible });
} catch (cause) {
await setDockVisibility(settings.show_dock_icon).catch(() => {});
throw cause;
}
}
export async function checkForUpdate(): Promise<Update | null> { export async function checkForUpdate(): Promise<Update | null> {
return check(); return check();
} }
-13
View File
@@ -1,13 +0,0 @@
export type DesktopPlatform = "macos" | "windows" | "linux";
export function desktopPlatform(): DesktopPlatform {
const configured = document.documentElement.dataset.platform;
if (configured === "macos" || configured === "windows" || configured === "linux") {
return configured;
}
const agent = navigator.userAgent;
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
if (/Windows/.test(agent)) return "windows";
return "linux";
}
+151 -200
View File
@@ -1,24 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api, type Model, type ModelInput } from "../api"; import { api, type Model, type ProviderSelection, type TabSettings } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates"; import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor"; import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult"; import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import { TabSettingsCard } from "../components/cursor/TabSettingsCard";
import styles from "../components/cursor/CursorSettings.module.scss"; import styles from "../components/cursor/CursorSettings.module.scss";
import { PageContent } from "../components/layout/PageContent"; import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import controls from "../components/ui/Controls.module.scss"; import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon"; import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal"; import { Modal } from "../components/ui/Modal";
import { TitledCard } from "../components/ui/TitledCard";
import { TooltipTrigger } from "../components/ui/TooltipTrigger"; import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { addIcon } from "../components/ui/icons"; import { addIcon, claudeIcon, editIcon, openAiIcon, trashIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message"; import { useMessage } from "../components/ui/message";
import { PageActions } from "../layouts/PageActions"; import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore"; import { appStore, useAppStore } from "../store/appStore";
export function CursorSettingsPage() { export function CursorSettingsPage() {
const { models, cursorHarness, cursorBusy } = useAppStore(); const { providers, models, cursorHarness, cursorBusy } = useAppStore();
const message = useMessage(); const message = useMessage();
const [draft, setDraft] = useState<CursorModelDraft | null>(null); const [draft, setDraft] = useState<CursorModelDraft | null>(null);
const [editing, setEditing] = useState<Model | null>(null); const [editing, setEditing] = useState<Model | null>(null);
@@ -27,123 +26,84 @@ export function CursorSettingsPage() {
const [caCommand, setCaCommand] = useState<string | null>(null); const [caCommand, setCaCommand] = useState<string | null>(null);
const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false); const [waitingForCaRefresh, setWaitingForCaRefresh] = useState(false);
const [deleting, setDeleting] = useState<Model | null>(null); const [deleting, setDeleting] = useState<Model | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings | null>(null);
const [savingTab, setSavingTab] = useState(false);
const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set()); const [testingModelHashes, setTestingModelHashes] = useState<Set<string>>(() => new Set());
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map()); const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false); const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false); const [batchTesting, setBatchTesting] = useState(false);
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat"); const grouped = useMemo(() => providers.map((provider) => ({ provider, models: models.filter((model) => model.provider_id === provider.provider_id) })).filter((group) => group.models.length > 0), [providers, models]);
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready"; const caReady = cursorHarness?.ca === "ready";
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
const canGroupByType = typeGroups.length > 1;
useEffect(() => { useEffect(() => {
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) { if (!caCommand) return;
setGrouping("flat"); void api.copyCursorText(caCommand);
}
}, [canGroupByProvider, canGroupByType, grouping]);
useEffect(() => {
if (caCommand) void api.copyCursorText(caCommand);
}, [caCommand]); }, [caCommand]);
useEffect(() => {
void api.tabSettings()
.then(setTabDraft)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const initializeCa = async () => { const initializeCa = async () => {
const status = await appStore.initializeCursorCa(); const status = await appStore.initializeCursorCa();
if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command); if (status?.ca === "untrusted" && status.ca_install_command) setCaCommand(status.ca_install_command);
}; };
const openNew = () => {
const next = emptyCursorModelDraft(); const openNew = () => { setEditing(null); setModelOptions([]); setDraft(emptyCursorModelDraft()); };
next.model.sort_order = models.length + 1;
setEditing(null);
setModelOptions([]);
setDraft(next);
};
const openEdit = (model: Model) => { const openEdit = (model: Model) => {
setEditing(model); const next = emptyCursorModelDraft();
setModelOptions([model.model_id]); next.providerMode = String(model.provider_id);
setDraft({ next.model = {
model: modelInput(model), model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2), endpoint_type: model.endpoint_type, request_url: model.request_url,
customHeadersText: JSON.stringify(model.custom_headers, null, 2), context_window_tokens: model.context_window_tokens, max_output_tokens: null,
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2), reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
}); supports_image_generation: model.supports_image_generation,
};
next.modelIds = [model.model_id];
next.customRequestUrl = Boolean(model.request_url);
setEditing(model); setModelOptions([model.model_id]); setDraft(next);
}; };
const providerSelection = (value: CursorModelDraft): ProviderSelection => value.providerMode === "new"
? { kind: "new", input: { ...value.provider, name: providerName(value.provider.base_url), custom_headers: parseHeaders(value.headersText), extra_params: parseObject(value.extraText, t("额外参数")) } }
: { kind: "existing", provider_id: Number(value.providerMode) };
const discover = async () => { const discover = async () => {
if (!draft) return; if (!draft) return;
setDiscovering(true); setDiscovering(true);
try { try {
const custom_headers = parseHeaders(draft.customHeadersText); const discovered = [...new Set((await api.discoverCursorModels(providerSelection(draft))).models)];
const result = await api.discoverModels({ const existing = draft.providerMode === "new"
type: draft.model.type, ? new Set<string>()
base_url: draft.model.base_url.trim(), : new Set(models.filter((model) => model.provider_id === Number(draft.providerMode)).map((model) => model.model_id));
api_key: draft.model.api_key.trim(), setModelOptions(editing ? discovered : discovered.filter((modelId) => !existing.has(modelId)));
custom_headers_enabled: draft.model.custom_headers_enabled,
custom_headers,
});
setModelOptions([...new Set(result.models)]);
} catch (cause) { } catch (cause) {
message(errorText(cause)); message(cause instanceof Error ? cause.message : String(cause));
} finally { } finally {
setDiscovering(false); setDiscovering(false);
} }
}; };
const persist = async (): Promise<Model | null> => {
if (!draft) return null;
const input = draftInput(draft);
if (editing) return appStore.updateCursorModel(editing.model_hash, input);
return (await appStore.createModels([input]))?.[0] ?? null;
};
const save = async () => { const save = async () => {
if (!draft) return;
try { try {
if (await persist()) { const modelInputs = cursorModelInputs(draft, editing !== null);
setDraft(null); const saved = editing
setEditing(null); ? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
} : await appStore.createCursorModels(providerSelection(draft), modelInputs);
} catch (cause) { if (saved) { setDraft(null); setEditing(null); }
message(errorText(cause)); } catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
}
}; };
const cancelModelTest = async (modelHash: string) => { const testModel = async (model: Model, notify = true) => {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
}
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
activeModelTests.current.set(model.model_hash, active);
setTestingModelHashes((current) => new Set(current).add(model.model_hash)); setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try { try {
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal); const result = await api.testModel(model.model_hash);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result })); setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms })); if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return "success"; return true;
} catch (cause) { } catch (cause) {
if (active.cancelling || active.controller.signal.aborted) { const error = cause instanceof Error ? cause.message : String(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
return "cancelled";
}
const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error })); setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 }); if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return "failure"; return false;
} finally { } finally {
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
setTestingModelHashes((current) => { setTestingModelHashes((current) => {
const next = new Set(current); const next = new Set(current);
next.delete(model.model_hash); next.delete(model.model_hash);
@@ -151,151 +111,116 @@ export function CursorSettingsPage() {
}); });
} }
}; };
const testSingleModel = async (model: Model) => {
await testModel(model);
await appStore.refresh();
};
const saveAndTest = async () => { const saveAndTest = async () => {
if (!draft || !editing) return;
setSavingAndTesting(true); setSavingAndTesting(true);
let saved: Model | null = null;
try { try {
saved = await persist(); const [input] = cursorModelInputs(draft, true);
const saved = await appStore.updateCursorModel(editing.model_hash, input);
if (!saved) {
const error = appStore.getSnapshot().error;
if (error) message(error);
return;
}
setEditing(saved);
await testSingleModel(saved);
} catch (cause) { } catch (cause) {
message(errorText(cause)); message(cause instanceof Error ? cause.message : String(cause));
} finally { } finally {
setSavingAndTesting(false); setSavingAndTesting(false);
} }
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
}; };
const testAllModels = async () => { const testAllModels = async () => {
if (!models.length || batchTesting) return; if (!models.length || batchTesting) return;
const targets = [...models];
setBatchTesting(true); setBatchTesting(true);
try { try {
const results = await Promise.all(models.map((model) => testModel(model, false))); const results = await Promise.all(targets.map((model) => testModel(model, false)));
const successful = results.filter((result) => result === "success").length; await appStore.refresh();
const failed = results.filter((result) => result === "failure").length; const successful = results.filter(Boolean).length;
const cancelled = results.filter((result) => result === "cancelled").length; const failed = targets.length - successful;
message(cancelled > 0 message(failed === 0
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed }) ? t("全部 {count} 个模型连通性测试成功", { count: targets.length })
: failed === 0 : t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 });
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally { } finally {
setBatchTesting(false); setBatchTesting(false);
} }
}; };
const duplicateModel = async (model: Model) => { const list = <div className={styles.groups}>{grouped.map(({ provider, models: childModels }) => <TitledCard key={provider.provider_id} title={<div className={styles.providerTitle}><Icon icon={provider.provider_type === "anthropic" ? claudeIcon : openAiIcon} /><span>{provider.name}</span></div>}>
const names = new Set(models.map((item) => item.display_name)); <div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}>
const baseName = t("{name} 副本", { name: model.display_name }); <div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div>
let displayName = baseName; {/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */}
let suffix = 2; {modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />}
while (names.has(displayName)) { <div className={styles.rowActions}>
displayName = `${baseName} ${suffix}`; <button type="button" className={`${controls.secondary} ${controls.small}`} disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting} onClick={() => void testSingleModel(model)}>{testingModelHashes.has(model.model_hash) ? t("测试中…") : t("测试")}</button>
suffix += 1; <TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
} <TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
const created = await appStore.createModels([{ </div>
...modelInput(model), </div>)}</div>
sort_order: models.length + 1, </TitledCard>)}</div>;
display_name: displayName,
}]);
if (created) message(t("模型已复制"));
};
const reorderModels = useCallback(async (modelHashes: string[]) => {
if (!await appStore.reorderCursorModels(modelHashes)) {
message(appStore.getSnapshot().error || t("排序失败"));
}
}, [message]);
const list = <CursorModelCards
models={models}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
onEdit={openEdit}
onDuplicate={(model) => void duplicateModel(model)}
onDelete={setDeleting}
onReorder={reorderModels}
/>;
const refreshCa = async () => { const refreshCa = async () => {
await appStore.refresh(); await appStore.refresh();
if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false); if (appStore.getSnapshot().cursorHarness?.ca !== "ready") {
setWaitingForCaRefresh(false);
}
}; };
const openCaTerminal = () => { const openCaTerminal = () => {
if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause))); if (caCommand) void api.openCursorCaInstallTerminal(caCommand);
setCaCommand(null); setCaCommand(null);
setWaitingForCaRefresh(true); setWaitingForCaRefresh(true);
}; };
const saveTab = async () => {
if (!tabDraft) return;
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
setTabDraft(await api.setTabSettings(tabDraft));
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}> const content = <CursorCaProvider><CursorCaGate busy={cursorBusy} waitingForRefresh={waitingForCaRefresh} onInitialize={() => void initializeCa()} onRefresh={() => void refreshCa()}>
<div className={styles.page}> <div className={styles.page}>
<LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) => {tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />}
<CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{list}</CursorModelGate></CursorModelProvider> <CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider>
}</LegacyModelImport>
</div> </div>
</CursorCaGate></CursorCaProvider>; </CursorCaGate></CursorCaProvider>;
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
const estimatedModelHeight = grouping === "flat"
? Math.max(380, Math.ceil(models.length / 3) * 196)
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
return <> return <>
{models.length > 0 && <PageActions position="left"> {models.length > 0 && <PageActions position="left">
<div className={styles.groupActions} role="group" aria-label={t("操作")}> <button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button>
<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>
</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> <PageActions>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} /> <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>
<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()} 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>}> </PageActions>
<PageContent title={t("Cursor 设置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(430, models.length * 55 + grouped.length * 62 + 145), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={editing ? <button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button> : undefined}>
{draft && <> {draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} /> <CursorModelEditor draft={draft} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
{editing && modelTestResults.get(editing.model_hash) && <div className={styles.editorTestResult}><CursorModelTestResult state={modelTestResults.get(editing.model_hash)!} /></div>}
</>} </>}
</Modal> </Modal>
<ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}> <Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}>
<div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div> <div className={styles.editor}>
</ConfirmDialog> <strong>{t("需要授权安装证书")}</strong>
<ConfirmDialog open={deleting !== null} title={t("删除模型")} cancelLabel={t("取消")} confirmLabel={t("删除")} onCancel={() => setDeleting(null)} onConfirm={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}><p>{t("确定删除这个模型吗?")}</p></ConfirmDialog> <span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span>
<pre className={styles.command}>{caCommand}</pre>
</div>
</Modal>
<Modal open={deleting !== null} title={t("删除模型")} closeLabel={t("取消")} submitLabel={t("删除")} onClose={() => setDeleting(null)} onSubmit={() => { if (deleting) void appStore.deleteModel(deleting.model_hash); setDeleting(null); }}>
<p>{t("确定删除这个模型吗?")}</p>
</Modal>
</>; </>;
} }
function modelInput(model: Model): ModelInput {
const { model_hash: _hash, created_at_ms: _created, updated_at_ms: _updated, ...input } = model;
return input;
}
function draftInput(draft: CursorModelDraft): ModelInput {
const model = {
...draft.model,
display_name: draft.model.display_name.trim(),
base_url: draft.model.base_url.trim(),
api_key: draft.model.api_key.trim(),
tooltip_data: draft.model.tooltip_data.trim(),
model_id: draft.model.model_id.trim(),
openai_extra_params: parseObject(draft.openAIExtraParamsText, t("OpenAI 额外参数")),
custom_headers: parseHeaders(draft.customHeadersText),
anthropic_extra_params: parseObject(draft.anthropicExtraParamsText, t("Anthropic 额外参数")),
};
if (!model.display_name || !model.base_url || !model.api_key || !model.tooltip_data || !model.model_id) throw new Error(t("服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空"));
for (const [label, value] of [[t("上下文窗口 Token"), model.context_window_tokens], [t("最大输出 Token"), model.type === "openai" ? model.max_completion_tokens : model.anthropic_max_tokens], [t("思考预算 Token"), model.thinking_budget_tokens]] as const) {
if (value !== null && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(t("{label} 必须是大于 0 的整数", { label }));
}
return model;
}
function parseHeaders(text: string): Record<string, string> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
return parsed as Record<string, string>;
}
function parseObject(text: string, label: string): Record<string, unknown> { function parseObject(text: string, label: string): Record<string, unknown> {
let parsed: unknown; let parsed: unknown;
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); } try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
@@ -303,6 +228,32 @@ function parseObject(text: string, label: string): Record<string, unknown> {
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} }
function errorText(cause: unknown) { function cursorModelInputs(draft: CursorModelDraft, editing: boolean) {
return cause instanceof Error ? cause.message : String(cause); const modelIds = editing
? [draft.model.model_id.trim()]
: [...new Set(draft.modelIds.map((modelId) => modelId.trim()).filter(Boolean))];
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数"));
return modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
display_name: modelIds.length === 1 ? draft.model.display_name.trim() || modelId : modelId,
sort_order: draft.model.sort_order + index,
}));
}
function providerName(baseUrl: string): string {
try {
return new URL(baseUrl.trim()).hostname;
} catch {
throw new Error(t("Base URL 必须是有效地址"));
}
}
function parseHeaders(text: string): Record<string, string> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string")) throw new Error(t("自定义 Headers 的值必须都是字符串"));
return parsed as Record<string, string>;
} }
+16 -4
View File
@@ -30,14 +30,16 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
} }
export function HomePage() { export function HomePage() {
const { overview, busy, models } = useAppStore(); const { overview, busy, models, providers } = useAppStore();
const [preset, setPreset] = useState<OverviewRangePreset>("month"); const [preset, setPreset] = useState<OverviewRangePreset>("month");
const [customRange, setCustomRange] = useState<TimeRange | null>(null); const [customRange, setCustomRange] = useState<TimeRange | null>(null);
const [customOpen, setCustomOpen] = useState(false); const [customOpen, setCustomOpen] = useState(false);
const [customStart, setCustomStart] = useState(""); const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState(""); const [customEnd, setCustomEnd] = useState("");
const [selectedModels, setSelectedModels] = useState<string[]>([]); const [selectedModels, setSelectedModels] = useState<string[]>([]);
const [selectedProviders, setSelectedProviders] = useState<string[]>([]);
const [appliedModels, setAppliedModels] = useState<string[]>([]); const [appliedModels, setAppliedModels] = useState<string[]>([]);
const [appliedProviders, setAppliedProviders] = useState<string[]>([]);
const [rangeOverview, setRangeOverview] = useState<Overview | null>(null); const [rangeOverview, setRangeOverview] = useState<Overview | null>(null);
const [rangeBusy, setRangeBusy] = useState(false); const [rangeBusy, setRangeBusy] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0); const [refreshVersion, setRefreshVersion] = useState(0);
@@ -50,13 +52,14 @@ export function HomePage() {
void api.overview({ void api.overview({
...selectedRange, ...selectedRange,
modelHashes: appliedModels, modelHashes: appliedModels,
providerIds: appliedProviders.map(Number),
}).then((next) => { }).then((next) => {
if (active) setRangeOverview(next); if (active) setRangeOverview(next);
}).finally(() => { }).finally(() => {
if (active) setRangeBusy(false); if (active) setRangeBusy(false);
}); });
return () => { active = false; }; return () => { active = false; };
}, [preset, customRange, overview, refreshVersion, appliedModels]); }, [preset, customRange, overview, refreshVersion, appliedModels, appliedProviders]);
const filteredOverview = rangeOverview ?? overview; const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({ const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
@@ -93,6 +96,7 @@ export function HomePage() {
if (startMs === null || endMs === null || startMs >= endMs) return; if (startMs === null || endMs === null || startMs >= endMs) return;
setCustomRange({ startMs, endMs }); setCustomRange({ startMs, endMs });
setAppliedModels(selectedModels); setAppliedModels(selectedModels);
setAppliedProviders(selectedProviders);
setPreset("custom"); setPreset("custom");
setCustomOpen(false); setCustomOpen(false);
}; };
@@ -104,7 +108,12 @@ export function HomePage() {
const modelOptions = models.map((model) => ({ const modelOptions = models.map((model) => ({
value: model.model_hash, value: model.model_hash,
label: model.display_name, label: model.display_name,
icon: iconFor(model.type), icon: iconFor(model.endpoint_type),
}));
const providerOptions = providers.map((provider) => ({
value: String(provider.provider_id),
label: provider.name,
icon: iconFor(provider.provider_type),
})); }));
const sections: VirtualPageSection[] = [ const sections: VirtualPageSection[] = [
{ {
@@ -118,7 +127,7 @@ export function HomePage() {
{ {
key: "metrics", key: "metrics",
estimatedHeight: 130, estimatedHeight: 130,
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />, content: <HomeMetrics data={metrics} />,
}, },
{ {
@@ -135,13 +144,16 @@ export function HomePage() {
customStart={customStart} customStart={customStart}
customEnd={customEnd} customEnd={customEnd}
modelOptions={modelOptions} modelOptions={modelOptions}
providerOptions={providerOptions}
selectedModels={selectedModels} selectedModels={selectedModels}
selectedProviders={selectedProviders}
busy={busy || rangeBusy} busy={busy || rangeBusy}
onSelect={(value) => { setPreset(value); setCustomOpen(false); }} onSelect={(value) => { setPreset(value); setCustomOpen(false); }}
onCustomOpenChange={openCustom} onCustomOpenChange={openCustom}
onCustomStartChange={setCustomStart} onCustomStartChange={setCustomStart}
onCustomEndChange={setCustomEnd} onCustomEndChange={setCustomEnd}
onSelectedModelsChange={setSelectedModels} onSelectedModelsChange={setSelectedModels}
onSelectedProvidersChange={setSelectedProviders}
onCustomApply={applyCustom} onCustomApply={applyCustom}
onRefresh={() => void refresh()} onRefresh={() => void refresh()}
/></PageActions> /></PageActions>
@@ -0,0 +1,3 @@
.pageContent {
height: 100%;
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from "react";
import type { Provider, ProviderInput } from "../api";
import { ProviderEditor } from "../components/ProviderEditor";
import { ProviderTable } from "../components/ProviderTable";
import { PageContent } from "../components/layout/PageContent";
import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { addIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message";
import { PageActions } from "../layouts/PageActions";
import { appStore, useAppStore } from "../store/appStore";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../utils/providerDefaults";
import styles from "./ProvidersPage.module.scss";
const emptyProvider = (): ProviderInput => ({
name: "",
provider_type: "openai-chat",
base_url: "",
api_key: "",
custom_headers: { ...defaultCustomHeaders },
extra_params: {},
});
export function ProvidersPage() {
const { providers, busy } = useAppStore();
const message = useMessage();
const [draft, setDraft] = useState<ProviderInput | null>(null);
const [editing, setEditing] = useState<Provider | null>(null);
const [headersText, setHeadersText] = useState(defaultCustomHeadersText);
const [extraText, setExtraText] = useState("{}");
const [saving, setSaving] = useState(false);
const openNew = () => {
setEditing(null);
setDraft(emptyProvider());
setHeadersText(defaultCustomHeadersText);
setExtraText("{}");
};
const openEdit = (provider: Provider) => {
setEditing(provider);
setDraft({ name: provider.name, provider_type: provider.provider_type, base_url: provider.base_url, api_key: provider.api_key ?? "", custom_headers: provider.custom_headers, extra_params: provider.extra_params });
setHeadersText(JSON.stringify(provider.custom_headers, null, 2));
setExtraText(JSON.stringify(provider.extra_params, null, 2));
};
const closeEditor = () => {
if (saving) return;
setDraft(null);
setEditing(null);
};
const save = async () => {
if (!draft) return;
try {
const name = draft.name.trim();
const baseUrl = draft.base_url.trim();
if (!name || !baseUrl) throw new Error(t("名称和 Base URL 不能为空"));
const input: ProviderInput = {
...draft,
name,
base_url: baseUrl,
api_key: editing && !draft.api_key?.trim() ? undefined : draft.api_key,
custom_headers: parseHeaders(headersText),
extra_params: parseObject(extraText, t("额外参数")),
};
setSaving(true);
const ok = editing ? await appStore.updateProvider(editing.provider_id, input) : await appStore.createProvider(input);
if (ok) {
setDraft(null);
setEditing(null);
}
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const content = <ProviderTable providers={providers} onEdit={openEdit} onDelete={(provider) => void appStore.deleteProvider(provider.provider_id)} />;
return <>
<PageActions><TooltipTrigger label={t("添加上游")}><button className={controls.iconButton} aria-label={t("添加上游")} disabled={busy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent fixed title={t("上游")} contentClassName={styles.pageContent} sections={[{ key: "providers", estimatedHeight: 720, content }]} />
<Modal open={draft !== null} title={editing ? t("编辑上游") : t("添加上游")} busy={saving} onClose={closeEditor} onSubmit={() => void save()}>
{draft && <ProviderEditor value={draft} headersText={headersText} extraText={extraText} editing={editing !== null} onChange={setDraft} onHeadersChange={setHeadersText} onExtraChange={setExtraText} />}
</Modal>
</>;
}
function parseHeaders(text: string): Record<string, string | null> {
const parsed = parseObject(text, t("自定义 Headers"));
if (Object.values(parsed).some((value) => typeof value !== "string" && value !== null)) throw new Error(t("自定义 Headers 的值必须是字符串或 null"));
return parsed as Record<string, string | null>;
}
function parseObject(text: string, label: string): Record<string, unknown> {
let parsed: unknown;
try { parsed = JSON.parse(text || "{}"); } catch { throw new Error(t("{label} 必须是有效 JSON", { label })); }
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error(t("{label} 必须是 JSON 对象", { label }));
return parsed as Record<string, unknown>;
}
@@ -41,24 +41,6 @@
} }
} }
.importRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px;
> div {
display: grid;
gap: 5px;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.textButton { .textButton {
padding: 4px; padding: 4px;
color: var(--vscode-textLink-foreground); color: var(--vscode-textLink-foreground);
@@ -150,11 +132,6 @@
.portFields { .portFields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.importRow {
align-items: stretch;
flex-direction: column;
}
} }
.themeActions .selected { .themeActions .selected {
+11 -55
View File
@@ -1,14 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api"; import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api";
import { PageContent } from "../components/layout/PageContent"; import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard"; import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
import { ProxySettingsCard } from "../components/settings/ProxySettingsCard"; import { ProxySettingsCard } from "../components/settings/ProxySettingsCard";
import { TabSettingsCard } from "../components/settings/TabSettingsCard";
import { Button } from "../components/ui/Button"; import { Button } from "../components/ui/Button";
import { Checkbox } from "../components/ui/Checkbox"; import { Checkbox } from "../components/ui/Checkbox";
import { ConfirmDialog } from "../components/ui/ConfirmDialog";
import { FormField, TextInput } from "../components/ui/FormControls"; import { FormField, TextInput } from "../components/ui/FormControls";
import { Modal } from "../components/ui/Modal";
import { Select } from "../components/ui/Select"; import { Select } from "../components/ui/Select";
import { TitledCard } from "../components/ui/TitledCard"; import { TitledCard } from "../components/ui/TitledCard";
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store"; import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
@@ -32,17 +30,11 @@ export function SettingsPage() {
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" }); const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
const [editingProxy, setEditingProxy] = useState(false); const [editingProxy, setEditingProxy] = useState(false);
const [savingProxy, setSavingProxy] = useState(false); const [savingProxy, setSavingProxy] = useState(false);
const [tabSettings, setTabSettings] = useState<TabSettings | null>(null);
const [tabDraft, setTabDraft] = useState<TabSettings>({ mode: "public", address: "" });
const [editingTab, setEditingTab] = useState(false);
const [savingTab, setSavingTab] = useState(false);
useEffect(() => { useEffect(() => {
void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => { void Promise.all([api.statisticsStorage(), api.proxySettings()]).then(([nextStorage, nextProxy]) => {
setStorage(nextStorage); setStorage(nextStorage);
setOutboundProxy(nextProxy); setOutboundProxy(nextProxy);
setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" }); setProxyDraft({ mode: nextProxy.mode, address: nextProxy.address, auth_enabled: nextProxy.auth_enabled, username: nextProxy.username, password: "" });
setTabSettings(nextTab);
setTabDraft(nextTab);
}).catch((cause) => message(cause instanceof Error ? cause.message : String(cause))); }).catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]); }, [message]);
useEffect(() => { useEffect(() => {
@@ -122,30 +114,6 @@ export function SettingsPage() {
setSavingProxy(false); setSavingProxy(false);
} }
}; };
const editTab = () => {
if (!tabSettings) return;
setTabDraft(tabSettings);
setEditingTab(true);
};
const cancelTabEdit = () => {
if (tabSettings) setTabDraft(tabSettings);
setEditingTab(false);
};
const saveTab = async () => {
try {
if (tabDraft.mode === "custom" && !tabDraft.address.trim()) throw new Error(t("TAB 服务地址不能为空"));
setSavingTab(true);
const saved = await api.setTabSettings({ ...tabDraft, address: tabDraft.address.trim() });
setTabSettings(saved);
setTabDraft(saved);
setEditingTab(false);
message(t("TAB 设置已保存"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setSavingTab(false);
}
};
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"]; const units = ["KB", "MB", "GB", "TB"];
@@ -223,19 +191,7 @@ export function SettingsPage() {
</div> </div>
</TitledCard> </TitledCard>
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} /> <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()} />
<AppLifecycleSettingsCard /> <AppLifecycleSettingsCard />
<LegacyModelImport>{({ busy, previewing, open }) => <TitledCard title={t("导入")}>
<div className={styles.importRow}>
<div>
<strong>{t("旧版配置")}</strong>
<small>{t("从本机旧版配置读取模型;确认前会显示新增和已存在的模型。")}</small>
</div>
<Button size="small" disabled={busy} onClick={open}>
{previewing ? t("读取中…") : t("查看并导入")}
</Button>
</div>
</TitledCard>}</LegacyModelImport>
<TitledCard title={t("语言")}> <TitledCard title={t("语言")}>
<div className={styles.settingRow}> <div className={styles.settingRow}>
<div> <div>
@@ -284,24 +240,24 @@ export function SettingsPage() {
</button> </button>
</div> </div>
</TitledCard> </TitledCard>
<ConfirmDialog <Modal
open={confirmClear} open={confirmClear}
title={t("确定要清理所有统计数据吗?")} title={t("确定要清理所有统计数据吗?")}
busy={clearing} busy={clearing}
cancelLabel={t("取消")} closeLabel={t("取消")}
confirmLabel={t("确认清理")} submitLabel={t("确认清理")}
onCancel={() => setConfirmClear(false)} onClose={() => setConfirmClear(false)}
onConfirm={() => void clearStorage()} onSubmit={() => void clearStorage()}
> >
<div className={styles.confirmContent}> <div className={styles.confirmContent}>
<small> <small>
{t( {t(
"所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。", "所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。",
)} )}
</small> </small>
</div> </div>
</ConfirmDialog> </Modal>
</div> </div>
); );
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 1200, content }]} />; return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />;
} }
+78 -48
View File
@@ -1,8 +1,11 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api"; import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings, type Provider, type ProviderInput, type ProviderSelection } from "../api";
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme"; import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
type Discovery = { provider: Provider; modelIds: string[] };
export type AppSnapshot = { export type AppSnapshot = {
providers: Provider[];
models: Model[]; models: Model[];
calls: LlmCall[]; calls: LlmCall[];
overview: Overview; overview: Overview;
@@ -10,6 +13,8 @@ export type AppSnapshot = {
ports: PortSettings; ports: PortSettings;
busy: boolean; busy: boolean;
error: string | null; error: string | null;
discoveringProviderId: number | null;
discovery: Discovery | null;
theme: ThemeId; theme: ThemeId;
cursorHarness: CursorHarnessStatus | null; cursorHarness: CursorHarnessStatus | null;
cursorBusy: boolean; cursorBusy: boolean;
@@ -21,6 +26,7 @@ const savedTheme = (): ThemeId => {
}; };
let snapshot: AppSnapshot = { let snapshot: AppSnapshot = {
providers: [],
models: [], models: [],
calls: [], calls: [],
overview: { overview: {
@@ -42,6 +48,8 @@ let snapshot: AppSnapshot = {
ports: { proxy_port: 0, service_port: 0 }, ports: { proxy_port: 0, service_port: 0 },
busy: false, busy: false,
error: null, error: null,
discoveringProviderId: null,
discovery: null,
theme: savedTheme(), theme: savedTheme(),
cursorHarness: null, cursorHarness: null,
cursorBusy: false, cursorBusy: false,
@@ -73,7 +81,8 @@ export const appStore = {
async refresh() { async refresh() {
update({ busy: true, error: null }); update({ busy: true, error: null });
try { try {
const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([ const [providers, models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
api.providers(),
api.models(), api.models(),
api.calls(), api.calls(),
api.overview(), api.overview(),
@@ -81,7 +90,7 @@ export const appStore = {
api.ports(), api.ports(),
api.cursorHarness(), api.cursorHarness(),
]); ]);
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness }); update({ providers, models, calls, overview, detailed: settings.detailed, ports, cursorHarness });
} catch (cause) { } catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) }); update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally { } finally {
@@ -89,6 +98,68 @@ export const appStore = {
} }
}, },
async createProvider(input: ProviderInput) {
try {
update({ error: null });
await api.createProvider(input);
await appStore.refresh();
return true;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
}
},
async updateProvider(providerId: number, input: ProviderInput) {
try {
update({ error: null });
await api.updateProvider(providerId, input);
await appStore.refresh();
return true;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return false;
}
},
async deleteProvider(providerId: number) {
await perform(async () => {
await api.deleteProvider(providerId);
await appStore.refresh();
});
},
async discoverModels(provider: Provider) {
update({ discoveringProviderId: provider.provider_id, error: null });
try {
const result = await api.discoverModels(provider.provider_id);
const existing = new Set(snapshot.models.filter((model) => model.provider_id === provider.provider_id).map((model) => model.model_id));
update({ discovery: { provider, modelIds: result.models.filter((id) => !existing.has(id)) } });
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
} finally {
update({ discoveringProviderId: null });
}
},
async addModel(modelId: string) {
const discovery = snapshot.discovery;
if (!discovery) return;
await perform(async () => {
await api.saveModels(discovery.provider.provider_id, [{
model_id: modelId,
display_name: modelId,
endpoint_type: discovery.provider.provider_type,
request_url: "",
enabled: true,
sort_order: snapshot.models.length,
context_window_tokens: null,
max_output_tokens: null,
reasoning_enabled: false,
reasoning_effort: null,
supports_image_generation: false,
}]);
update({ discovery: { ...discovery, modelIds: discovery.modelIds.filter((id) => id !== modelId) } });
await appStore.refresh();
});
},
async deleteModel(modelHash: string) { async deleteModel(modelHash: string) {
await perform(async () => { await perform(async () => {
await api.deleteModel(modelHash); await api.deleteModel(modelHash);
@@ -113,26 +184,15 @@ export const appStore = {
catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); }
finally { update({ cursorBusy: false }); } finally { update({ cursorBusy: false }); }
}, },
async createModels(models: ModelInput[]) { async createCursorModels(provider: ProviderSelection, models: ModelInput[]) {
update({ cursorBusy: true, error: null }); update({ cursorBusy: true, error: null });
try { try {
const created = await api.createModels(models); await api.createCursorModels(provider, models);
await appStore.refresh(); await appStore.refresh();
return created; return true;
} catch (cause) { } catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) }); update({ error: cause instanceof Error ? cause.message : String(cause) });
return null; return false;
} finally { update({ cursorBusy: false }); }
},
async importV0049Models() {
update({ cursorBusy: true, error: null });
try {
const result = await api.importV0049Models();
await appStore.refresh();
return result;
} catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) });
return null;
} finally { update({ cursorBusy: false }); } } finally { update({ cursorBusy: false }); }
}, },
async updateCursorModel(hash: string, model: ModelInput) { async updateCursorModel(hash: string, model: ModelInput) {
@@ -146,36 +206,6 @@ export const appStore = {
return null; return null;
} finally { update({ cursorBusy: false }); } } finally { update({ cursorBusy: false }); }
}, },
async reorderCursorModels(modelHashes: string[]) {
const previous = snapshot.models;
const byHash = new Map(previous.map((model) => [model.model_hash, model]));
if (modelHashes.length !== previous.length || new Set(modelHashes).size !== previous.length) {
update({ error: t("模型配置已发生变化,请刷新后重试") });
return false;
}
const reordered: Model[] = [];
for (const [index, hash] of modelHashes.entries()) {
const model = byHash.get(hash);
if (!model) {
update({ error: t("模型配置已发生变化,请刷新后重试") });
return false;
}
reordered.push({ ...model, sort_order: index + 1 });
}
update({ models: reordered, cursorBusy: true, error: null });
try {
update({ models: await api.reorderModels(modelHashes) });
return true;
} catch (cause) {
update({
models: previous,
error: cause instanceof Error ? cause.message : String(cause),
});
return false;
} finally {
update({ cursorBusy: false });
}
},
async openCallDetails(callId: string) { async openCallDetails(callId: string) {
await perform(() => api.openCallDetails(callId)); await perform(() => api.openCallDetails(callId));
+1 -1
View File
@@ -90,7 +90,7 @@
sideBar-border: #00000024, sideBar-border: #00000024,
list-activeSelectionBackground: #0000000f, list-activeSelectionBackground: #0000000f,
list-activeSelectionForeground: #191919, list-activeSelectionForeground: #191919,
list-hoverBackground: #f3f3f3, list-hoverBackground: #E6E6E67A,
input-background: #ffffff, input-background: #ffffff,
input-foreground: #191919, input-foreground: #191919,
input-border: #00000024, input-border: #00000024,
+1 -1
View File
@@ -3,5 +3,5 @@ $font-size-xs: 13px;
$font-size-base: 14px; $font-size-base: 14px;
$font-size-lg: 16px; $font-size-lg: 16px;
$font-size-xl: 18px; $font-size-xl: 18px;
$font-size-xxl: 20px; $font-size-xxl: 24px;
$font-size-3xl: 30px; $font-size-3xl: 30px;
-130
View File
@@ -1,130 +0,0 @@
import type { ModelType } from "../api";
import deepseekIcon from "../assets/provider-icons/deepseek.svg";
import huoshanIcon from "../assets/provider-icons/huoshan.png";
import kimiIcon from "../assets/provider-icons/kimi.svg";
import minimaxIcon from "../assets/provider-icons/minimax.svg";
import zhipuIcon from "../assets/provider-icons/zhipu.svg";
import { defaultCustomHeaders } from "./modelDefaults";
export interface ModelPresetEntry {
model_id: string;
display_name: string;
context_window_tokens: number | null;
max_output_tokens: number | null;
}
/** 一个服务商在某种协议(anthropic / openai)下的接入端点 */
export interface ModelPresetEndpoint {
baseUrl: string;
/** true 时 baseUrl 即完整请求 URL;false 时由请求协议追加标准端点路径 */
useFullUrl: boolean;
/** openai 协议的请求端点(useFullUrl 为 true 时忽略) */
openaiEndpoint: string;
/** 非空时启用自定义 Headers(claude-cli 伪装头) */
customHeaders: Record<string, string> | null;
}
export interface ModelPreset {
key: string;
name: string;
icon: string;
keyHint: string;
/** 五家服务商均同时提供 Anthropic 与 OpenAI 兼容协议 */
endpoints: { anthropic: ModelPresetEndpoint; openai: ModelPresetEndpoint };
models: ModelPresetEntry[];
}
const entry = (
modelId: string,
displayName: string,
contextWindowTokens: number | null,
maxOutputTokens: number | null,
): ModelPresetEntry => ({
model_id: modelId,
display_name: displayName,
context_window_tokens: contextWindowTokens,
max_output_tokens: maxOutputTokens,
});
const claudeHeaders = { ...defaultCustomHeaders };
/** anthropic 协议:填 Base URL,自动追加 /v1/messages */
const anthropic = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "", customHeaders: claudeHeaders });
/** openai 协议:填 Base URL,自动追加 /v1/chat/completions */
const openaiChat = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
/** openai 协议:路径不规则,直接给完整请求 URL */
const openaiFullUrl = (url: string): ModelPresetEndpoint => ({ baseUrl: url, useFullUrl: true, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
export const modelPresets: ModelPreset[] = [
{
key: "zhipu",
name: "智谱 GLM",
icon: zhipuIcon,
keyHint: "bigmodel.cn → GLM Coding Plan → API Key(套餐 Key 与普通 Key 不通用)",
endpoints: {
anthropic: anthropic("https://open.bigmodel.cn/api/anthropic"),
openai: openaiFullUrl("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"),
},
models: [
entry("glm-5.3", "GLM 5.3", 1000000, 65536),
entry("glm-5.2", "GLM 5.2", 200000, 32768),
entry("glm-4.7", "GLM 4.7", 200000, 32768),
],
},
{
key: "kimi",
name: "Kimi (Moonshot)",
icon: kimiIcon,
keyHint: "Kimi Code 编程套餐页获取 API Key(api.kimi.com/coding 端点)",
endpoints: {
anthropic: anthropic("https://api.kimi.com/coding"),
openai: openaiChat("https://api.kimi.com/coding"),
},
models: [
entry("k3", "Kimi K3", 1048576, 65536),
entry("kimi-for-coding", "K2.7 Coding", 262144, 32768),
],
},
{
key: "deepseek",
name: "DeepSeek",
icon: deepseekIcon,
keyHint: "platform.deepseek.com → API Keys",
endpoints: {
anthropic: anthropic("https://api.deepseek.com/anthropic"),
openai: openaiChat("https://api.deepseek.com"),
},
models: [
entry("deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 65536),
entry("deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, null),
],
},
{
key: "volcengine",
name: "火山引擎方舟",
icon: huoshanIcon,
keyHint: "火山方舟 Coding Plan(ark-code-latest 路由多款代码模型)",
endpoints: {
anthropic: anthropic("https://ark.cn-beijing.volces.com/api/coding"),
openai: openaiFullUrl("https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"),
},
models: [entry("ark-code-latest", "Ark Code Latest", 256000, 32768)],
},
{
key: "minimax",
name: "MiniMax",
icon: minimaxIcon,
keyHint: "platform.minimaxi.com → 订阅 Coding Plan → API Key",
endpoints: {
anthropic: anthropic("https://api.minimaxi.com/anthropic"),
openai: { baseUrl: "https://api.minimaxi.com", useFullUrl: false, openaiEndpoint: "/v1/responses", customHeaders: null },
},
models: [
entry("MiniMax-M3", "MiniMax M3", 1000000, 65536),
entry("MiniMax-M2.7", "MiniMax M2.7", 205000, 32768),
],
},
];
export const trimTrailingSlash = (url: string) => url.replace(/\/+$/, "");
export const presetEndpoint = (preset: ModelPreset, type: ModelType): ModelPresetEndpoint => preset.endpoints[type];
+1 -1
View File
@@ -8,5 +8,5 @@
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite*.config.ts", "plugins/**/*.ts"] "include": ["vite.config.ts", "plugins/**/*.ts"]
} }
-22
View File
@@ -1,22 +0,0 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.ts";
const desktopRoot = fileURLToPath(new URL("./", import.meta.url));
const demoInput = fileURLToPath(new URL("./demo/index.html", import.meta.url));
const demoOutput = fileURLToPath(new URL("../docs/public/product-demo", import.meta.url));
export default defineConfig({
root: desktopRoot,
base: "/product-demo/",
plugins: [staticI18nPlugin(), react()],
publicDir: false,
build: {
outDir: demoOutput,
emptyOutDir: true,
rollupOptions: {
input: demoInput,
},
},
});
-29
View File
@@ -1,29 +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
-9
View File
@@ -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
View File
@@ -1 +0,0 @@
@AGENTS.md
-48
View File
@@ -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`。
-61
View File
@@ -1,61 +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';
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params;
const page = blogSource.getPage([slug]);
if (!page) notFound();
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="/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" />
返回开发者博客
</Link>
<header className="border-b pb-8">
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(page.path)}</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">cursor-byok 开发团队</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 blogSource.getPages().map((page) => ({ slug: page.slugs[0] }));
}
export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {
const { slug } = await props.params;
const page = blogSource.getPage([slug]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}
-44
View File
@@ -1,44 +0,0 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { ArrowRight } from 'lucide-react';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
export const metadata: Metadata = {
title: '开发者博客',
description: 'cursor-byok 的架构决策、协议实现与开发进展。',
};
export default function BlogPage() {
const posts = sortBlogPages(blogSource.getPages());
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">开发者博客</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
记录 cursor-byok 的架构决策、协议实现和开发进展。
</p>
</div>
<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)}</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>
</main>
);
}
-6
View File
@@ -1,6 +0,0 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/'>) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
-135
View File
@@ -1,135 +0,0 @@
import Link from 'next/link';
import {
ArrowRight,
BookOpen,
Download,
Settings2,
Wrench,
} from 'lucide-react';
import { DesktopDemo } from '@/components/hero/DesktopDemo';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { appDescription, releaseUrl } from '@/lib/shared';
const docs = [
{
icon: BookOpen,
title: '快速开始',
description: '完成安装、初始化和第一次模型调用。',
href: '/docs',
},
{
icon: Settings2,
title: '模型配置',
description: '配置协议、服务地址、凭据和生成参数。',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: '故障排查',
description: '解决证书、连接和模型测试问题。',
href: '/docs/troubleshooting',
},
];
export default function HomePage() {
const posts = sortBlogPages(blogSource.getPages()).slice(0, 3);
return (
<main className="flex flex-1 flex-col">
<section className="border-b px-4 pb-16 pt-20 sm:px-6 sm:pb-24 sm:pt-28">
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<p className="font-mono text-sm font-medium text-fd-primary">开源 · 本地运行 · 自由接入</p>
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl">
{appDescription}
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
在本机运行自己的模型网关,接入 OpenAI、Anthropic 等兼容服务,继续使用 Cursor Agent 的工具调用、Skills 和 MCP。
</p>
<div className="mt-10 flex flex-wrap justify-center gap-3">
<Link
href="/docs"
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"
>
阅读文档
<ArrowRight className="size-4" />
</Link>
<a
href={releaseUrl}
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"
>
<Download className="size-4" />
下载最新版
</a>
</div>
</div>
<DesktopDemo />
</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">DOCUMENTATION</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">文档</h2>
</div>
<Link href="/docs" className="hidden items-center gap-2 text-sm font-medium sm:flex">
查看全部
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{docs.map(({ icon: Icon, title, description, href }) => (
<Link
key={href}
href={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>
<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">DEVELOPER BLOG</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">开发者博客</h2>
</div>
<Link href="/blog" className="flex items-center gap-2 text-sm font-medium">
查看全部
<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)}</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>
</main>
);
}
-4
View File
@@ -1,4 +0,0 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
-63
View File
@@ -1,63 +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<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<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<'/docs/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImageUrl(page).url,
},
};
}
-11
View File
@@ -1,11 +0,0 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
{children}
</DocsLayout>
);
}
-16
View File
@@ -1,16 +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;
}
-26
View File
@@ -1,26 +0,0 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18nProvider } from 'fumadocs-ui/i18n';
import type { Metadata } from 'next';
import { translations } from '@/lib/layout.shared';
import './global.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: 'cursor-byok 文档',
template: '%s | cursor-byok',
},
description: 'cursor-byok 的安装、模型配置与故障排查指南。',
};
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="flex min-h-screen flex-col">
<RootProvider i18n={i18nProvider(translations)}>{children}</RootProvider>
</body>
</html>
);
}
-10
View File
@@ -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,23 +0,0 @@
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageMarkdownUrl(page).segments,
}));
}
-8
View File
@@ -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());
}
-28
View File
@@ -1,28 +0,0 @@
import { getPageImageUrl, source } from '@/lib/source';
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<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
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 source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImageUrl(page).segments,
}));
}

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