Compare commits

...
Author SHA1 Message Date
leokun 14d6a501a0 chore: release v0.1.4 2026-08-27 11:30:54 +08:00
leokun 82f3ae8d59 feat(settings): add dock icon visibility setting to desktop settings
- Introduced a new setting `show_dock_icon` in the `DesktopSettings` interface.
- Updated `AppLifecycleSettingsCard` to manage dock icon visibility with a toggle switch.
- Enhanced the API to read and write the new dock icon visibility setting.
- Added corresponding translations for the new setting in both English and Chinese locales.
- Updated tests to ensure the default behavior of showing the dock icon and proper round-trip functionality for settings.
2026-08-27 11:24:04 +08:00
leokun 31dfa8d295 refactor: improve MCP parameter normalization and test clarity
- Changed `normalize_mcp_parameters` to avoid mutating the input parameters.
- Updated the test name to better reflect its purpose of preserving the cursor object union schema.
- Adjusted assertions in the test to compare the entire schema instead of individual fields.
2026-08-27 11:11:13 +08:00
leookun 995a12df44 chore: update dependencies and improve chart components
- Updated `foreign-types` to version 0.5.0 and added new dependencies in `Cargo.lock`.
- Removed unused dependencies `chart.js` and `react-chartjs-2` from `package.json` and `package-lock.json`.
- Enhanced `CacheHitRateChart` to use `EChart` for rendering, improving performance and visual fidelity.
- Adjusted styles for `CacheHitRateChart` to ensure proper layout and responsiveness.
- Refactored API query construction in `api.ts` for better readability.
- Updated `HomeMetrics` to support refresh functionality for dynamic data updates.
2026-08-27 00:34:35 +08:00
leokun 7058d0193d Merge pull request #356 from GGboya/feat/provider-presets
feat(console): dual-protocol provider presets + fix sub-path model di…
2026-08-27 00:12:14 +08:00
leookun eff642fdcf Merge branch 'main' into pr/GGboya/356 2026-08-26 23:28:11 +08:00
leokun 2c95d7f836 fix: finalize draft releases by asset id 2026-08-26 22:44:27 +08:00
ggboysongyuhangandClaude Fable 5 75babb7334 feat(console): dual-protocol provider presets + fix sub-path model discovery
Console — provider presets inside the model editor:

- Preset chips for five coding-plan providers (Zhipu GLM / Kimi / DeepSeek /
  Volcengine Ark / MiniMax) rendered at the top of the add/edit model dialog
- All five providers expose both an Anthropic-protocol and an
  OpenAI-protocol endpoint; a click fills the endpoint matching the current
  model type, and switching the type automatically swaps to the same
  provider's other endpoint (no stale URL/protocol mismatches)
- Switching to a different provider clears the API key; switching protocols
  within the same provider keeps it
- Fills base URL (full-request-URL form for irregular paths), request
  protocol, claude-cli custom headers for Anthropic endpoints, token limits,
  remark, and the default model id + display name; known preset model ids
  merge into the model-name combobox

Server — model discovery for sub-path endpoints:

- model_discovery_url previously replaced the whole path with /v1/models,
  which strips the /api/anthropic, /coding, /api/paas/v4 style prefixes every
  coding-plan provider uses, so discovery always 404'd for them. It now
  appends to the existing path and normalizes endpoint/version suffixes
  (/chat/completions, /responses, /messages, /v1, /v3, /v4)
- Verified live against Zhipu (10 models) and Kimi (4 models) coding plans;
  existing tests updated to the append semantics plus new URL cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 22:43:24 +08:00
leokun f6be6c2b27 release: v0.1.3 2026-08-26 22:09:36 +08:00
leokun ded534fecf feat: update UI 2026-08-26 22:01:14 +08:00
leokun 5450fc76e2 fix: shell 2026-08-26 20:20:38 +08:00
leokun df053c3720 fix: harden concurrent persistence and task recovery 2026-08-26 16:46:41 +08:00
leookun 9deb42915c release: v0.1.2 2026-08-26 15:56:02 +08:00
leookun 1bc1c3d978 merge old config 2026-08-26 15:50:53 +08:00
leookun e1937233ec merge old config 2026-08-26 15:46:11 +08:00
leookun 34f97334dc test: update compaction fixtures for main 2026-08-26 01:51:00 +08:00
leookun ab7b5e8c00 merge: fix compaction input usage anchor 2026-08-26 01:49:15 +08:00
leookun 78ff002aad fix: anchor compaction to provider input usage 2026-08-26 01:49:12 +08:00
leookun 847e92c7ea refactor: update cursor request handling and improve parent request management
- Changed `run_id` to `request_id` in `CursorParent` struct for clarity.
- Enhanced the `prepare` function to handle parent requests asynchronously, ensuring proper error handling for active runs.
- Updated database interactions to include `cursor_request_id` for better tracking of requests.
- Added tests to verify the behavior of reused cursor request IDs and their mapping to distinct executions.
2026-08-26 01:46:30 +08:00
leokun ae31757635 Merge pull request #349 from linanwanttodo/main
feat: 支持 Linux 平台使用(CA 安装适配 + 跨平台静默启动),并修复 api_key 泄漏
2026-08-26 00:56:43 +08:00
leookun a773888852 fix: show window on manual launch 2026-08-26 00:40:56 +08:00
leookun bdf4b923d7 fix: preserve provider API key echo 2026-08-26 00:31:14 +08:00
Linanwanttodo d95273c49b feat: 支持 Linux 平台使用(CA 安装适配 + 静默启动),并修复 api_key 泄漏
CA 适配:
- 移除 CA 模块对 macOS/Windows 的硬性限制,Linux 走完整初始化流程
- 按发行版自动选择安装命令:Debian 系 update-ca-certificates,
  Fedora/RHEL/Arch 系 update-ca-trust extract;已安装检测比对信任锚文件
- 删除不可达的 CaState::Unsupported 分支及前端"请使用 macOS 或 Windows"提示

静默启动(参考 cc-switch 实现逻辑):
- 新增 DesktopSettings 持久化(service_settings 表)及 GET/PUT /api/settings/desktop
- 主窗口统一以 visible(false) 创建,启动时读取配置决定是否显示
- 应用设置页:开启"开机启动"后显示"静默启动"子开关

安全修复:
- ProviderEndpoint.api_key 增加 serde(skip_serializing),
  避免明文 API Key 随 JSON 响应泄漏(provider_console 测试恢复通过)

其他:
- Linux 补齐 open_terminal_with_command:按序探测 x-terminal-emulator /
  gnome-terminal / konsole 等并在新终端窗口执行命令
- "打开终端安装 CA"失败信息不再被前端静默吞掉
- 顺手修复上游遗留问题:desktop.rs 未使用导入、output.rs 测试模块位置、providers.rs 格式
2026-08-25 22:59:21 +08:00
leokun 081e1f50e2 feat: NormalizedProvider 2026-08-25 20:50:57 +08:00
leookun 5f87357681 fix: restore Windows shell parsing metadata 2026-08-25 13:31:14 +08:00
166 changed files with 9655 additions and 4333 deletions
+30
View File
@@ -213,6 +213,36 @@ 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
+121 -3
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", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]] [[package]]
name = "cursor-byok-desktop" name = "cursor-byok-desktop"
version = "0.1.0-beta.10" version = "0.1.4"
dependencies = [ dependencies = [
"axum", "axum",
"cursor-server", "cursor-server",
@@ -1183,6 +1183,7 @@ 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",
@@ -1904,6 +1905,15 @@ 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"
@@ -1911,7 +1921,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", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -1925,6 +1935,12 @@ 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"
@@ -2726,6 +2742,22 @@ 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"
@@ -3639,6 +3671,23 @@ 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"
@@ -4037,12 +4086,49 @@ 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"
@@ -5042,9 +5128,11 @@ 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",
@@ -5055,6 +5143,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls",
"tokio-rustls", "tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
@@ -5661,6 +5750,19 @@ 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"
@@ -6910,6 +7012,16 @@ 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"
@@ -7487,6 +7599,12 @@ 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"
+17 -32
View File
@@ -1,12 +1,12 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0-beta.10", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0-beta.10", "version": "0.1.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.8.0", "@floating-ui/dom": "^1.8.0",
@@ -18,15 +18,14 @@
"@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": {
@@ -37,6 +36,7 @@
"@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,12 +425,6 @@
"@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",
@@ -1293,6 +1287,13 @@
"@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",
@@ -1808,18 +1809,6 @@
"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",
@@ -2989,16 +2978,6 @@
"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",
@@ -3185,6 +3164,12 @@
"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 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "cursor-byok-desktop", "name": "cursor-byok-desktop",
"version": "0.1.0-beta.10", "version": "0.1.4",
"description": "Cursor BYOK desktop management application", "description": "Cursor BYOK desktop management application",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -28,15 +28,14 @@
"@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,6 +46,7 @@
"@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.0-beta.10" version = "0.1.4"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -16,6 +16,7 @@
"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",
+52 -14
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,
WebviewWindowBuilder, WebviewWindow, WebviewWindowBuilder,
}; };
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -30,6 +30,7 @@ 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,
@@ -52,12 +53,30 @@ fn open_terminal_with_command(command: String) -> tauri::Result<()> {
.spawn()?; .spawn()?;
Ok(()) Ok(())
} }
#[cfg(not(any(target_os = "macos", target_os = "windows")))] #[cfg(target_os = "linux")]
{ {
let _ = command; const TERMINALS: &[(&str, &[&str])] = &[
("x-terminal-emulator", &["-e"]),
("gnome-terminal", &["--"]),
("konsole", &["-e"]),
("xfce4-terminal", &["--execute"]),
("alacritty", &["-e"]),
("kitty", &[]),
];
let script = format!("{command}; exec bash");
for (terminal, separator) in TERMINALS {
let mut process = Command::new(terminal);
process.args(*separator);
process.arg("bash").arg("-c").arg(&script);
match process.spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
}
}
Err(tauri::Error::from(std::io::Error::new( Err(tauri::Error::from(std::io::Error::new(
std::io::ErrorKind::Unsupported, std::io::ErrorKind::NotFound,
"terminal guidance is unsupported on this platform", "no supported terminal emulator found",
))) )))
} }
} }
@@ -96,7 +115,10 @@ fn desktop_api_router(app: AppHandle) -> Router {
.layer(Extension(app)) .layer(Extension(app))
} }
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> { fn create_main_window(
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");
@@ -108,18 +130,20 @@ fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::
.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()
@@ -130,17 +154,19 @@ 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, _, _| { .plugin(tauri_plugin_single_instance::init(|app, args, _| {
tray::show_main_window(app); if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
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(|app| { .setup(move |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,
None, Some(vec![AUTOSTART_ARG]),
))?; ))?;
let config = Config::desktop()?; let config = Config::desktop()?;
#[cfg(dev)] #[cfg(dev)]
@@ -160,6 +186,12 @@ 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();
@@ -176,7 +208,13 @@ pub fn run() {
server: Mutex::new(Some(task)), server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false), exiting: AtomicBool::new(false),
}); });
create_main_window(app.handle(), address)?; let window = 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.0-beta.10", "version": "0.1.4",
"identifier": "dev.cursorbyok.desktop", "identifier": "dev.cursorbyok.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
-2
View File
@@ -8,7 +8,6 @@ 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";
@@ -22,7 +21,6 @@ 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 />} />
+87 -60
View File
@@ -1,60 +1,87 @@
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 ProviderType = "openai-chat" | "openai-responses" | "anthropic"; export type ModelType = "openai" | "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;
provider_id: number;
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number; sort_order: number;
context_window_tokens: number | null; display_name: string;
max_output_tokens: number | null; type: ModelType;
reasoning_enabled: boolean; base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string;
reasoning_effort: string | null; reasoning_effort: string | null;
supports_image_generation: boolean; 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;
max_completion_tokens: number | null;
anthropic_max_tokens: number | null;
anthropic_thinking_effort: string | null;
thinking_budget_tokens: number | null;
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;
context_window_tokens: number | null; display_name: string;
max_output_tokens: number | null; type: ModelType;
reasoning_enabled: boolean; base_url: string;
use_full_url: boolean;
api_key: string;
tooltip_data: string;
model_id: string;
reasoning_effort: string | null; reasoning_effort: string | null;
supports_image_generation: boolean; 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;
max_completion_tokens: number | null;
anthropic_max_tokens: number | null;
anthropic_thinking_effort: string | null;
thinking_budget_tokens: number | null;
}
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 {
@@ -66,7 +93,7 @@ export interface ModelConnectivityResult {
output: string; output: string;
} }
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported"; export type CaState = "missing" | "untrusted" | "ready" | "invalid";
export type IntegrationState = "disabled" | "enabled" | "degraded"; export type IntegrationState = "disabled" | "enabled" | "degraded";
export interface CursorHarnessStatus { export interface CursorHarnessStatus {
platform: string; platform: string;
@@ -114,6 +141,11 @@ 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;
@@ -142,10 +174,6 @@ 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";
@@ -256,29 +284,26 @@ 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) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }), testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => { cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams(); 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.size ? `?${params}` : ""; const query = params.toString();
return request<Overview>(`/overview${query}`); return request<Overview>(`/overview${query ? `?${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) => {
@@ -310,4 +335,6 @@ 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) }),
}; };
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 711 B

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -1,15 +0,0 @@
.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; }
}
@@ -1,30 +0,0 @@
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>;
}
@@ -1,14 +0,0 @@
@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;
}
@@ -1,31 +0,0 @@
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,5 +1,7 @@
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";
@@ -54,8 +56,6 @@ 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,9 +72,10 @@ 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[]) { function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
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);
@@ -88,20 +89,22 @@ function buildCalendarLayout(data: ContributionDay[]) {
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: `${date.getUTCMonth() + 1}月`, column: cell.column }); if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), 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), [data]); const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout; layoutRef.current = layout;
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1,11 +1,11 @@
import { BarChart, LineChart } from "echarts/charts"; import { BarChart, GaugeChart, 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, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]); use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = { type EChartProps = {
option: EChartsCoreOption; option: EChartsCoreOption;
@@ -15,12 +15,11 @@ 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>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong> <strong>{installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span> <span>{installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>} <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>
</div>; </div>;
} }
@@ -29,12 +28,15 @@ 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({ onAdd, children }: { onAdd: () => void; children: ReactNode }) { export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => 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>
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button> <div className={styles.gateActions}>
<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>;
} }
@@ -0,0 +1,186 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
onTest: (model: Model) => void;
onEdit: (model: Model) => void;
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
</div>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
const reorder = useRef(onReorder);
currentModels.current = models;
reorder.current = onReorder;
useEffect(() => {
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
draggable: `.${styles.modelCard}`,
handle: `.${styles.sortHandle}`,
ghostClass: styles.sortGhost,
chosenClass: styles.sortChosen,
dragClass: styles.sortDragging,
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
onEnd: (event) => {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (typeof oldIndex !== "number"
|| typeof newIndex !== "number"
|| oldIndex === newIndex) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
const reordered = currentModels.current.slice();
const [moved] = reordered.splice(oldIndex, 1);
if (!moved || newIndex < 0 || newIndex > reordered.length) {
sortable.current?.sort(currentModels.current.map((model) => model.model_hash), false);
return;
}
reordered.splice(newIndex, 0, moved);
reorder.current(reordered.map((model) => model.model_hash));
},
});
return () => {
sortable.current?.destroy();
sortable.current = null;
};
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
sortable.current?.sort(models.map((model) => model.model_hash), false);
}, [disabled, models]);
return <div ref={grid} className={styles.modelGrid}>
{models.map((model) => {
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
{model.type === "anthropic" ? "Anthropic" : "OpenAI"}
</span>
</div>
<div className={styles.modelCardTest}>
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
</div>
</div>
</Card>;
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -1,101 +1,204 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api"; import type { ModelInput, ModelType } from "../../api";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls"; 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 { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
import { JsonEditor } from "../ui/JsonEditor"; import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select"; import { Combobox, 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 { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults"; import { CursorPresetChips } from "./CursorPresetChips";
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;
modelIds: string[]; openAIExtraParamsText: string;
headersText: string; customHeadersText: string;
extraText: string; anthropicExtraParamsText: string;
customRequestUrl: boolean;
}; };
export const emptyCursorModelDraft = (): CursorModelDraft => ({ export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerMode: "new", model: {
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} }, sort_order: 0,
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 }, display_name: "",
modelIds: [], type: "openai",
headersText: defaultCustomHeadersText, base_url: "",
extraText: "{}", use_full_url: false,
customRequestUrl: false, api_key: "",
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, providers, editing, modelOptions, discovering, onChange, onDiscover }: { export function CursorModelEditor({ draft, 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 canDiscover = draft.providerMode !== "new" const setType = (type: ModelType) => {
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim()); // 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
const selectProvider = (providerMode: string) => { // 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配
const endpointType = providerMode === "new" const other: ModelType = type === "anthropic" ? "openai" : "anthropic";
? draft.provider.provider_type const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()));
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type; const endpoint = preset ? presetEndpoint(preset, type) : null;
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model }); onChange({
...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 setEndpointType = (endpoint_type: ProviderType) => onChange({ const numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
...draft, const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider, // 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现)
model: { ...draft.model, endpoint_type }, const presetModelOptions = modelPresets
}); .filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
const setModelIds = (modelIds: string[]) => onChange({ .flatMap((preset) => preset.models.map((item) => item.model_id));
...draft, const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
modelIds, const applyPreset = (preset: ModelPreset) => {
model: { const endpoint = presetEndpoint(preset, draft.model.type);
...draft.model, const first = preset.models[0];
model_id: modelIds[0] ?? "", // 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name, const currentBase = trimTrailingSlash(draft.model.base_url.trim());
}, const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai]
}); .some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase);
return <div className={styles.editor}> onChange({
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[ ...draft,
{ value: "new", label: t("新建上游") }, model: {
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })), ...draft.model,
]} onChange={selectProvider} /></FormField>} 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}>
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
<div className={styles.grid}> <div className={styles.grid}>
{!editing && draft.providerMode === "new" && <> <FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
<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: "openai", label: "OpenAI", icon: openAiIcon },
<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> { value: "anthropic", label: "Anthropic", icon: claudeIcon },
</>} ]} onChange={(value) => setType(value as ModelType)} /></FormField>
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[ {draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon }, { value: "/v1/responses", label: "Responses API" },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField> { value: "/v1/chat/completions", label: "Chat Completions API" },
{(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>} ]} onChange={(openai_endpoint) => setModel({ openai_endpoint })} /></FormField>}
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /> <div className={styles.urlField}>
: <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 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>
}</FormField> <Checkbox checked={draft.model.use_full_url} label={t("使用完整请求地址")} onChange={(use_full_url) => setModel({ use_full_url })} />
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
</>}
<div className={`${styles.switches} ${styles.fullWidth}`}>
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
</div> </div>
<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>
</>}
<ToggleJsonField
label={t("自定义 Headers")}
enabled={draft.model.custom_headers_enabled}
text={draft.customHeadersText}
onEnabledChange={(custom_headers_enabled) => setModel({ custom_headers_enabled })}
onTextChange={(customHeadersText) => onChange({ ...draft, customHeadersText })}
/>
{draft.model.type === "openai" ? <ToggleJsonField
label={t("OpenAI 额外参数")}
enabled={draft.model.openai_extra_params_enabled}
text={draft.openAIExtraParamsText}
onEnabledChange={(openai_extra_params_enabled) => setModel({ openai_extra_params_enabled })}
onTextChange={(openAIExtraParamsText) => onChange({ ...draft, openAIExtraParamsText })}
/> : <ToggleJsonField
label={t("Anthropic 额外参数")}
enabled={draft.model.anthropic_extra_params_enabled}
text={draft.anthropicExtraParamsText}
onEnabledChange={(anthropic_extra_params_enabled) => setModel({ anthropic_extra_params_enabled })}
onTextChange={(anthropicExtraParamsText) => onChange({ ...draft, anthropicExtraParamsText })}
/>}
</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" },
];
}
@@ -0,0 +1,64 @@
@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,13 +2,18 @@ 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 "./CursorSettings.module.scss"; import styles from "./CursorModelTestResult.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) })
@@ -24,9 +29,9 @@ export function CursorModelTestResult({ state }: { state: CursorModelTestState }
}) })
: t("测试失败:{error}", { error: state.error }); : t("测试失败:{error}", { error: state.error });
return <div className={`${styles.testResult} ${success ? styles.testSuccess : styles.testError}`}> return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
<span className={styles.testResultText}>{summary}</span> <span className={styles.summary}>{summary}</span>
<TooltipTrigger label={detail}><span className={styles.testResultHint} tabIndex={0}><Icon icon={informationOutlineIcon} size="1.1em" /></span></TooltipTrigger> <TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
</div>; </div>;
} }
@@ -0,0 +1,47 @@
.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;
}
@@ -0,0 +1,25 @@
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,6 +5,35 @@
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;
@@ -29,103 +58,144 @@
} }
} }
.groups { .modelGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.modelGroups {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.modelGroupHeader {
display: flex;
align-items: center;
gap: 6px;
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; gap: 12px;
} }
.providerTitle { .modelCardTop {
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: row; align-items: flex-start;
align-items: center; justify-content: space-between;
gap: 3px; gap: 12px;
}
.modelCardName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
span { .modelCardNameText,
.modelCardModelId {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
} .modelCardNameText {
.models { color: var(--vscode-foreground);
display: flex; font-size: type.$font-size-base;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
small { .modelCardModelId {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs; font-size: type.$font-size-2xs;
} }
} }
.badge { .modelTypeBadge {
padding: 2px 7px; flex: 0 0 auto;
color: var(--vscode-badge-foreground); display: inline-flex;
background: var(--vscode-badge-background); align-items: center;
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;
} }
.rowActions { .modelCardTest {
display: flex; min-height: 31px;
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;
} }
.testSuccess { .modelCardActions {
color: var(--vscode-testing-iconPassed, #73c991); display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
} }
.testError { .deleteButton:hover {
color: var(--vscode-errorForeground, #f48771); color: var(--vscode-errorForeground, #f48771);
} }
.testResultText { .sortHandle {
min-width: 0; position: absolute;
overflow: hidden; top: 8px;
text-overflow: ellipsis; left: 8px;
white-space: nowrap; z-index: 1;
} width: 30px;
.testResultHint { height: 30px;
flex: 0 0 auto; display: grid;
display: flex; place-items: center;
align-items: center; padding: 0;
justify-content: center; color: transparent;
color: currentColor; background: transparent;
cursor: help; border: 1px solid transparent;
&:focus-visible { border-radius: 6px;
outline: 1px solid var(--vscode-focusBorder); opacity: 0;
outline-offset: 2px; 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 {
opacity: 0.4;
}
.sortChosen {
border-color: var(--vscode-focusBorder);
}
.sortDragging {
cursor: grabbing;
} }
.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;
@@ -145,6 +215,17 @@
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;
@@ -160,6 +241,21 @@
} }
} }
.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;
@@ -1,51 +0,0 @@
@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%;
}
}
@@ -1,53 +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, 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: 10px; padding-left:0;
font-size: type.$font-size-lg; font-size: type.$font-size-xxl;
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: 10px; padding-left: 0;
font-size: type.$font-size-lg; font-size: type.$font-size-xxl;
font-weight: bolder; font-weight: bolder;
transform: translateY(-150%); transform: translateY(-150%);
} }
@@ -1,18 +1,22 @@
@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: 132px; width: 200px;
max-width: 100%;
height: 82px; height: 82px;
overflow: hidden;
align-self: center; align-self: center;
flex: 0 0 auto; flex: 0 0 auto;
} }
.canvas { .chart {
width: 100% !important; position: absolute;
height: 100% !important; top: 0;
left: 0;
width: 100%;
height: 140px;
pointer-events: none;
} }
.label { .label {
@@ -1,75 +1,62 @@
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js"; import type { EChartsCoreOption } from "echarts/core";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { Doughnut } from "react-chartjs-2"; import { EChart } from "../charts/EChart";
import styles from "./CacheHitRateChart.module.scss"; import styles from "./CacheHitRateChart.module.scss";
ChartJS.register(ArcElement, Tooltip); const valueColor = "#40c463";
const trackColor = "rgba(139, 148, 158, 0.20)";
type SegmentRadius = number | { export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: 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(() => ({
labels: [t("命中"), t("未命中")], useEffect(() => {
datasets: [{ setDisplayedPercentage(0);
data: [percentage, Math.max(0, 100 - percentage)], let frame = requestAnimationFrame(() => {
backgroundColor: chartColor, frame = requestAnimationFrame(() => setDisplayedPercentage(percentage));
borderWidth: 0, });
hoverBorderWidth: 0, return () => cancelAnimationFrame(frame);
selfJoin: false, }, [animationKey, percentage]);
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 }],
}], }],
}), [percentage]); }), [displayedPercentage]);
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 })}>
<Doughnut className={styles.canvas} data={data} options={options} /> <EChart className={styles.chart} option={option} />
<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 }: { data: HomeMetricsData }) { export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
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 }: { data: HomeMetricsData }) {
<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} /> <CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
</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>
@@ -0,0 +1,92 @@
@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;
}
}
@@ -0,0 +1,92 @@
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: 30px; height: 25px;
padding: 0 8px; padding: 0 8px;
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
background: transparent; background: transparent;
@@ -11,22 +11,19 @@ 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, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: { export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, 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;
}) { }) {
@@ -109,7 +106,6 @@ 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,6 +97,7 @@ 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<{
@@ -107,6 +108,7 @@ 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,
@@ -132,7 +134,10 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
) )
const setContentRef = useCallback( const setContentRef = useCallback(
(node: HTMLDivElement | null) => assignRef(contentRef, node), (node: HTMLDivElement | null) => {
localContentRef.current = node
assignRef(contentRef, node)
},
[contentRef] [contentRef]
) )
@@ -33,6 +33,7 @@ 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
@@ -131,6 +132,7 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) { export function useScrollableModel(options: UseScrollableModelOptions) {
const { const {
viewportRef, viewportRef,
contentRef,
trackRef, trackRef,
thumbRef, thumbRef,
contentHeight, contentHeight,
@@ -319,28 +321,30 @@ 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 || !track) return if (!element || !content || !track) return
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0] const viewportEntry = entries.find((entry) => entry.target === element)
if (entry) { if (viewportEntry) {
onViewportResizeRef.current?.({ onViewportResizeRef.current?.({
width: entry.contentRect.width, width: viewportEntry.contentRect.width,
height: entry.contentRect.height, height: viewportEntry.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()
} }
}, [snapshot, trackRef, viewportRef]) }, [contentRef, snapshot, trackRef, viewportRef])
useEffect(() => { useEffect(() => {
snapshot(false) snapshot(false)
@@ -1,9 +1,13 @@
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";
@@ -15,10 +19,14 @@ 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;
@@ -28,6 +36,17 @@ 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]);
@@ -45,6 +64,32 @@ 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();
@@ -75,6 +120,30 @@ 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>
@@ -0,0 +1,78 @@
@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;
}
}
@@ -0,0 +1,90 @@
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>;
}
+4 -5
View File
@@ -1,12 +1,11 @@
import type { ElementType, ReactNode } from "react"; import type { ElementType, HTMLAttributes, ReactNode } from "react";
import styles from "./Card.module.scss"; import styles from "./Card.module.scss";
type CardProps = { type CardProps = HTMLAttributes<HTMLElement> & {
as?: ElementType; as?: ElementType;
className?: string;
children: ReactNode; children: ReactNode;
}; };
export function Card({ as: Component = "div", className, children }: CardProps) { export function Card({ as: Component = "div", className, children, ...props }: CardProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>; return <Component {...props} className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
} }
@@ -8,6 +8,7 @@
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;
@@ -0,0 +1,33 @@
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: 30px; width: 25px;
height: 30px; height: 25px;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 0; padding: 0;
@@ -47,6 +47,16 @@
.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,12 +34,14 @@
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;
@@ -52,15 +54,19 @@
width: min(1080px, calc(100vw - 48px)); width: min(1080px, calc(100vw - 48px));
} }
.body { .fullHeight {
flex: 0 1 auto; height: calc(100vh - var(--window-header-height) - 24px);
min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
} }
.bodyViewport { .banner {
height: auto; flex: 0 0 auto;
max-height: calc(100vh - var(--window-header-height) - 112px); padding: 12px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
}
.body {
flex: 1 1 auto;
min-height: 0;
} }
.bodyContent { .bodyContent {
+74 -9
View File
@@ -1,36 +1,101 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useId, 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";
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 }) { type ModalProps = {
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) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); }; const onKey = (event: KeyboardEvent) => {
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);
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus()); const focusFrame = requestAnimationFrame(() => {
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();
}; };
}, [open]); }, [initialFocus, 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].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}> <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}>
<header>{title}</header> <header id={titleId}>{title}</header>
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent> {banner && <div className={styles.banner}>{banner}</div>}
<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 type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>} {onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
</footer> </footer>
</div> </div>
</div>, document.body); </div>, document.body);
+2 -1
View File
@@ -5,7 +5,7 @@ import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement; export type TooltipAnchor = VirtualElement;
export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) { export function Tooltip({ id, anchor, children }: { id?: string; 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,6 +32,7 @@ export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; ch
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, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react"; import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, 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,26 +6,43 @@ function anchorFor(element: HTMLElement): TooltipAnchor {
} }
type TriggerProps = { type TriggerProps = {
onMouseEnter?: MouseEventHandler<HTMLElement>; "aria-describedby"?: string;
onMouseLeave?: MouseEventHandler<HTMLElement>; onPointerMove?: PointerEventHandler<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, {
onMouseEnter: (event) => { "aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
children.props.onMouseEnter?.(event); onPointerMove: (event) => {
setAnchor(anchorFor(event.currentTarget)); children.props.onPointerMove?.(event);
if (event.pointerType !== "touch" && event.buttons === 0) {
const element = event.currentTarget;
setAnchor((current) => current ?? anchorFor(element));
}
}, },
onMouseLeave: (event) => { onPointerLeave: (event) => {
children.props.onMouseLeave?.(event); children.props.onPointerLeave?.(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(anchorFor(event.currentTarget)); setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
}, },
onBlur: (event) => { onBlur: (event) => {
children.props.onBlur?.(event); children.props.onBlur?.(event);
@@ -35,6 +52,6 @@ export function TooltipTrigger({ label, children }: { label: string; children: R
return <> return <>
{trigger} {trigger}
<Tooltip anchor={anchor}>{label}</Tooltip> <Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
</>; </>;
} }
+1 -1
View File
@@ -15,7 +15,6 @@ 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>');
@@ -32,6 +31,7 @@ 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"/>');
File diff suppressed because it is too large Load Diff
+74 -58
View File
@@ -1,27 +1,25 @@
{ {
"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}",
"07ec86e0f1d44f91": "Created", "07c657ed4747126e": "Anthropic extra parameters",
"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",
@@ -31,12 +29,10 @@
"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…",
"15d76f1c211cc140": "Leave blank to keep the current API Key.", "15730c19fd7eef51": "Request protocol",
"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",
@@ -45,41 +41,50 @@
"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)",
"1cef73a53d7ad0f2": "For example: OpenAI", "1d27f02ed278ebc9": "Configuration file",
"1f9b46e61efcccb5": "Filter: {label}, {summary}", "1f9b46e61efcccb5": "Filter: {label}, {summary}",
"21296ab18ad9af25": "Extra parameters JSON", "202064bb84804852": "Leave blank to use the default.",
"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",
"2f1b67cdaa23351a": "Custom full request URL", "2f280a8120c0db12": "Used only for display and does not change the model name sent to the model service.",
"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",
"3bd5b3f538e3270a": "New provider", "3a8c76b2ce785f96": "Review and import",
"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)",
@@ -88,13 +93,18 @@
"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",
@@ -102,15 +112,19 @@
"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",
"5cca0b7972a11f3a": "Custom context must be an integer greater than 0", "5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"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?",
@@ -121,6 +135,7 @@
"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}",
@@ -128,7 +143,8 @@
"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}",
"68152165b3348852": "Select the request protocol used by this provider.", "680680288a6d2ad2": "Show in Dock",
"68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings", "68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time", "6a9906c79f26c0ba": "Start time",
"6aa8f49cc992dfd7": "Test", "6aa8f49cc992dfd7": "Test",
@@ -136,101 +152,103 @@
"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}",
"71eb2c6fce991a29": "Add provider", "72644ec4389da2f7": "Default layout",
"722e156c20650a3f": "Use macOS or Windows.", "736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"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",
"7e9ab9ada2cbf2cb": "Name and Base URL are required", "7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
"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",
"84924374710e03bd": "Base URL must be a valid URL", "843ac7e15a5047a7": "Confirm legacy model configuration import",
"864597982c308d72": "Silent start enabled",
"86b7355ec3bd55ef": "Hide API Key", "86b7355ec3bd55ef": "Hide API Key",
"8716e1344b0daddb": "Cursor official", "8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions", "878a8ab176429a86": "View instructions",
"883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.", "89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
"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",
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.", "9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort", "9c41b3a9e12ac994": "Reasoning effort",
"9d8f2ef4e85ea665": "Custom context tokens", "9e356080c56877f8": "Silent start disabled",
"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",
"a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.", "a1b8c98f29374a2f": "Silent start",
"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",
"b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.", "b6725f218ebaef26": "Dock icon shown",
"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",
@@ -239,24 +257,25 @@
"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",
"d15a909c3490a7e0": "Endpoint type", "d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
"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…",
"d44e9b3d3b31d37b": "Name", "d58c88688e1a949d": "Presets",
"d667c1616e31c4c4": "Leave blank to keep the current key", "d60669bb26a22f5d": "Leave blank to use the default",
"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",
"db0a1393f05aa4bd": "Inherits the provider by default; override it for this model if needed.", "daede9881787abe7": "Notes",
"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",
@@ -264,32 +283,29 @@
"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",
+74 -58
View File
@@ -1,27 +1,25 @@
{ {
"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}",
"07ec86e0f1d44f91": "创建时间", "07c657ed4747126e": "Anthropic 额外参数",
"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 消耗",
@@ -31,12 +29,10 @@
"13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。", "13a9ac7a68c5fd96": "CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。",
"13b61c5f697b6700": "缓存命中率", "13b61c5f697b6700": "缓存命中率",
"146da2e2a991493e": "获取中…", "146da2e2a991493e": "获取中…",
"15d76f1c211cc140": "留空表示保留当前 API Key。", "15730c19fd7eef51": "请求协议",
"168e845a86bc3703": "添加模型", "168e845a86bc3703": "添加模型",
"16aa4ae1427fccce": "自定义 Headers JSON",
"16d0d7e2b332af72": "总调用:{count}", "16d0d7e2b332af72": "总调用:{count}",
"1813d362a82fd437": "最大化窗口", "1813d362a82fd437": "最大化窗口",
"18d25065b3f7db39": "请求完整地址不能为空",
"19658d9fa9aa8de4": "安装中…", "19658d9fa9aa8de4": "安装中…",
"1a3f0617d6de8e52": "用户名", "1a3f0617d6de8e52": "用户名",
"1ae6b0a0f8266382": "关闭窗口", "1ae6b0a0f8266382": "关闭窗口",
@@ -45,41 +41,50 @@
"1c6926877b1bbfb2": "总请求:{tokens}", "1c6926877b1bbfb2": "总请求:{tokens}",
"1cb7e646ac883fb2": "代理端口", "1cb7e646ac883fb2": "代理端口",
"1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)", "1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)",
"1cef73a53d7ad0f2": "例如:OpenAI", "1d27f02ed278ebc9": "配置文件",
"1f9b46e61efcccb5": "筛选项:{label},{summary}", "1f9b46e61efcccb5": "筛选项:{label},{summary}",
"21296ab18ad9af25": "额外参数 JSON", "202064bb84804852": "留空时使用默认值。",
"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": "详细记录",
"2f1b67cdaa23351a": "自定义请求完整地址", "2f280a8120c0db12": "仅用于界面展示,不会改变发送给模型服务的模型名称。",
"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": "全选",
"3bd5b3f538e3270a": "新建上游", "3a8c76b2ce785f96": "查看并导入",
"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": "输入(非缓存)",
@@ -88,13 +93,18 @@
"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} 个模型连通性测试成功",
@@ -102,15 +112,19 @@
"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": "全部",
"5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数", "5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5d59857bf039cac9": "Cursor 助手 v{version}", "5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动", "5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?", "5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -121,6 +135,7 @@
"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}",
@@ -128,7 +143,8 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数", "65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}", "65cb9a7b4f620b6b": "提示词 {tokens}",
"68152165b3348852": "选择上游服务使用的请求协议。", "680680288a6d2ad2": "在 Dock 栏显示",
"68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置", "68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间", "6a9906c79f26c0ba": "开始时间",
"6aa8f49cc992dfd7": "测试", "6aa8f49cc992dfd7": "测试",
@@ -136,101 +152,103 @@
"6d1876364ac6457d": "代理方式", "6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本", "6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}", "7005693f4f050bce": "缓存读写 {cost}",
"71eb2c6fce991a29": "添加上游", "72644ec4389da2f7": "默认平铺",
"722e156c20650a3f": "请使用 macOS 或 Windows。", "736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"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": "统计数据已清理",
"7e9ab9ada2cbf2cb": "名称和 Base URL 不能为空", "7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
"7f3c8312816fe26a": "刷新中…", "7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新", "7f68ebad19ba6bcd": "检查更新",
"80a57e03f0717f91": "未配置",
"811a3b22a5a7f2d5": "无法连接本地管理服务", "811a3b22a5a7f2d5": "无法连接本地管理服务",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83fcfb4c1f2c1641": "获取模型", "83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动", "842b9f11cdd96bda": "开机启动",
"84924374710e03bd": "Base URL 必须是有效地址", "843ac7e15a5047a7": "确认导入旧版模型配置",
"864597982c308d72": "已开启静默启动",
"86b7355ec3bd55ef": "隐藏 API Key", "86b7355ec3bd55ef": "隐藏 API Key",
"8716e1344b0daddb": "Cursor 官方", "8716e1344b0daddb": "Cursor 官方",
"878a8ab176429a86": "查看说明", "878a8ab176429a86": "查看说明",
"883cc47637fe70f3": "附加到该上游所有请求的自定义请求头,值必须是字符串。", "89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
"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",
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。", "9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度", "9c41b3a9e12ac994": "思考强度",
"9d8f2ef4e85ea665": "自定义上下文 tokens", "9e356080c56877f8": "已关闭静默启动",
"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": "应用设置",
"a2901cd2743c1921": "模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。", "a1b8c98f29374a2f": "静默启动",
"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": "是",
"b710ec36ad312918": "模型服务的 API 根地址,例如 https://api.openai.com/v1。", "b6725f218ebaef26": "已显示 Dock 栏图标",
"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": "上一页",
@@ -239,24 +257,25 @@
"cea1aafe9416de7b": "请求头", "cea1aafe9416de7b": "请求头",
"cfae1a14d2120c57": "详细模式", "cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。", "cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月", "d0bfccc77315d887": "近一个月",
"d15a909c3490a7e0": "端点类型", "d1251cd752d4ec25": "留空时使用 adaptive thinking。",
"d27db596b73a0a66": "例如:272000",
"d2d648bd1c94b7f9": "认证", "d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。", "d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…", "d3d21191f32e79a5": "处理中…",
"d44e9b3d3b31d37b": "名称", "d58c88688e1a949d": "常用预设",
"d667c1616e31c4c4": "留空以保留当前密钥", "d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理", "d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单", "d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书", "da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时", "da7ae985487c38e6": "近1小时",
"db0a1393f05aa4bd": "默认继承上游,可为当前模型单独修改。", "daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求", "ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。", "dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
"df1baa9f706d970b": "将新增",
"df3d58c7d84b85f2": "设置", "df3d58c7d84b85f2": "设置",
"df8b71c74d9b8478": "响应流", "df8b71c74d9b8478": "响应流",
"dfb802238b38fbd4": "已启用", "dfb802238b38fbd4": "已启用",
@@ -264,32 +283,29 @@
"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 用量日历",
+1 -10
View File
@@ -1,4 +1,5 @@
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";
@@ -17,13 +18,3 @@ 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 -1
View File
@@ -1,7 +1,7 @@
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 "./AppFrame"; import type { DesktopPlatform } from "../native/platform";
import { WindowControls } from "./WindowControls"; import { WindowControls } from "./WindowControls";
import styles from "./AppHeader.module.scss"; import styles from "./AppHeader.module.scss";
@@ -156,11 +156,14 @@
.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: flex-end; justify-content: center;
padding: 2px 2px; padding: 6px 6px;
gap: 3px; gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
} }
.pageActions { .pageActions {
+33 -19
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, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons"; import { flatColorAboutIcon, flatColorAreaChartIcon, 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", "/providers", "/settings", "/harness/cursor"]; const keptAlivePages = ["/", "/calls", "/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,6 +54,7 @@ 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";
@@ -71,21 +72,23 @@ 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: "page", path: "/providers", label: t("上游配置"), icon: flatColorOrganizationIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
{ kind: "group", label: "Harness" }, { kind: "group", label: "Harness" },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon }, { kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 设置"), icon: cursorIconUrl }, { kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
]; ];
const openTutorial = useCallback(() => { const openTutorial = useCallback(() => {
setTutorialRead(true); setConfirmTutorial(false);
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]);
@@ -195,7 +198,7 @@ export function AppLayout() {
<button <button
type="button" type="button"
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`} aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
onClick={openTutorial} onClick={() => setConfirmTutorial(true)}
> >
{typeof item.icon === "string" {typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" /> ? <Icon src={item.icon} size="1.3em" />
@@ -218,14 +221,25 @@ 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} />}
<Modal <ConfirmDialog
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("不再显示此广告")}
closeLabel={t("取消")} cancelLabel={t("取消")}
submitLabel={t("确认")} confirmLabel={t("确认")}
onClose={closeDismissAd} onCancel={closeDismissAd}
onSubmit={() => void dismissAd()} onConfirm={() => void dismissAd()}
> >
<p>{t("你确认不想再看到此广告吗?")}</p> <p>{t("你确认不想再看到此广告吗?")}</p>
<label className={styles.dismissReason}> <label className={styles.dismissReason}>
@@ -238,7 +252,7 @@ export function AppLayout() {
onChange={(event) => setDismissReason(event.target.value)} onChange={(event) => setDismissReason(event.target.value)}
/> />
</label> </label>
</Modal> </ConfirmDialog>
<main className={styles.content}> <main className={styles.content}>
<div className={styles.actionRegion}> <div className={styles.actionRegion}>
<Card className={styles.actions}> <Card className={styles.actions}>
+27 -1
View File
@@ -1,13 +1,19 @@
import { getVersion } from "@tauri-apps/api/app"; import { getVersion, setDockVisibility } 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";
} }
@@ -20,6 +26,26 @@ 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();
} }
+8
View File
@@ -0,0 +1,8 @@
export type DesktopPlatform = "macos" | "windows" | "linux";
export function desktopPlatform(): DesktopPlatform {
const agent = navigator.userAgent;
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
if (/Windows/.test(agent)) return "windows";
return "linux";
}
+202 -153
View File
@@ -1,23 +1,24 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ProviderSelection, type TabSettings } from "../api"; import { api, type Model, type ModelInput } 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, claudeIcon, editIcon, openAiIcon, trashIcon } from "../components/ui/icons"; import { addIcon } 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 { providers, models, cursorHarness, cursorBusy } = useAppStore(); const { 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);
@@ -26,84 +27,123 @@ 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 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 [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
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 (!caCommand) return; if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
void api.copyCursorText(caCommand); setGrouping("flat");
}
}, [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 openNew = () => { setEditing(null); setModelOptions([]); setDraft(emptyCursorModelDraft()); };
const openEdit = (model: Model) => {
const next = emptyCursorModelDraft(); const next = emptyCursorModelDraft();
next.providerMode = String(model.provider_id); next.model.sort_order = models.length + 1;
next.model = { setEditing(null);
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order, setModelOptions([]);
endpoint_type: model.endpoint_type, request_url: model.request_url, setDraft(next);
context_window_tokens: model.context_window_tokens, max_output_tokens: null, };
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null, const openEdit = (model: Model) => {
supports_image_generation: model.supports_image_generation, setEditing(model);
}; setModelOptions([model.model_id]);
next.modelIds = [model.model_id]; setDraft({
next.customRequestUrl = Boolean(model.request_url); model: modelInput(model),
setEditing(model); setModelOptions([model.model_id]); setDraft(next); openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
anthropicExtraParamsText: JSON.stringify(model.anthropic_extra_params, null, 2),
});
}; };
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 discovered = [...new Set((await api.discoverCursorModels(providerSelection(draft))).models)]; const custom_headers = parseHeaders(draft.customHeadersText);
const existing = draft.providerMode === "new" const result = await api.discoverModels({
? new Set<string>() type: draft.model.type,
: new Set(models.filter((model) => model.provider_id === Number(draft.providerMode)).map((model) => model.model_id)); base_url: draft.model.base_url.trim(),
setModelOptions(editing ? discovered : discovered.filter((modelId) => !existing.has(modelId))); api_key: draft.model.api_key.trim(),
custom_headers_enabled: draft.model.custom_headers_enabled,
custom_headers,
});
setModelOptions([...new Set(result.models)]);
} catch (cause) { } catch (cause) {
message(cause instanceof Error ? cause.message : String(cause)); message(errorText(cause));
} finally { } finally {
setDiscovering(false); setDiscovering(false);
} }
}; };
const save = async () => { const persist = async (): Promise<Model | null> => {
if (!draft) return; if (!draft) return null;
try { const input = draftInput(draft);
const modelInputs = cursorModelInputs(draft, editing !== null); if (editing) return appStore.updateCursorModel(editing.model_hash, input);
const saved = editing return (await appStore.createModels([input]))?.[0] ?? null;
? await appStore.updateCursorModel(editing.model_hash, modelInputs[0])
: await appStore.createCursorModels(providerSelection(draft), modelInputs);
if (saved) { setDraft(null); setEditing(null); }
} catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); }
}; };
const testModel = async (model: Model, notify = true) => { const save = async () => {
try {
if (await persist()) {
setDraft(null);
setEditing(null);
}
} catch (cause) {
message(errorText(cause));
}
};
const cancelModelTest = async (modelHash: string) => {
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); const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
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 true; return "success";
} catch (cause) { } catch (cause) {
const error = cause instanceof Error ? cause.message : String(cause); if (active.cancelling || active.controller.signal.aborted) {
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 false; return "failure";
} 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);
@@ -111,116 +151,151 @@ 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 {
const [input] = cursorModelInputs(draft, true); saved = await persist();
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(cause instanceof Error ? cause.message : String(cause)); message(errorText(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(targets.map((model) => testModel(model, false))); const results = await Promise.all(models.map((model) => testModel(model, false)));
await appStore.refresh(); const successful = results.filter((result) => result === "success").length;
const successful = results.filter(Boolean).length; const failed = results.filter((result) => result === "failure").length;
const failed = targets.length - successful; const cancelled = results.filter((result) => result === "cancelled").length;
message(failed === 0 message(cancelled > 0
? t("全部 {count} 个模型连通性测试成功", { count: targets.length }) ? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }), { duration: failed === 0 ? 2400 : 5000 }); : failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally { } finally {
setBatchTesting(false); setBatchTesting(false);
} }
}; };
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 duplicateModel = async (model: Model) => {
<div className={styles.models}>{childModels.map((model) => <div className={styles.modelRow} key={model.model_hash}> const names = new Set(models.map((item) => item.display_name));
<div className={styles.modelName}><strong>{model.display_name}</strong><small>{model.model_id} · {model.model_hash}</small></div> const baseName = t("{name} 副本", { name: model.display_name });
{/* <span className={styles.badge}>{model.enabled ? t("已启用") : t("已停用")}</span> */} let displayName = baseName;
{modelTestResults.get(model.model_hash) && <CursorModelTestResult state={modelTestResults.get(model.model_hash)!} />} let suffix = 2;
<div className={styles.rowActions}> while (names.has(displayName)) {
<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> displayName = `${baseName} ${suffix}`;
<TooltipTrigger label={t("编辑模型")}><button className={controls.iconButton} aria-label={t("编辑模型")} onClick={() => openEdit(model)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger> suffix += 1;
<TooltipTrigger label={t("删除模型")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除模型")} onClick={() => setDeleting(model)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger> }
</div> const created = await appStore.createModels([{
</div>)}</div> ...modelInput(model),
</TitledCard>)}</div>; sort_order: models.length + 1,
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") { if (appStore.getSnapshot().cursorHarness?.ca !== "ready") setWaitingForCaRefresh(false);
setWaitingForCaRefresh(false);
}
}; };
const openCaTerminal = () => { const openCaTerminal = () => {
if (caCommand) void api.openCursorCaInstallTerminal(caCommand); if (caCommand) void api.openCursorCaInstallTerminal(caCommand).catch((cause) => message(errorText(cause)));
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}>
{tabDraft && <TabSettingsCard settings={tabDraft} saving={savingTab} onChange={setTabDraft} onSave={() => void saveTab()} />} <LegacyModelImport>{({ busy: importingLegacyModels, previewing, open }) =>
<CursorModelProvider><CursorModelGate onAdd={openNew}>{list}</CursorModelGate></CursorModelProvider> <CursorModelProvider><CursorModelGate busy={cursorBusy || importingLegacyModels} previewingImport={previewing} onAdd={openNew} onImport={open}>{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">
<button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button> <div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</div>
</PageActions>} </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>
<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> <PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
</PageActions> <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>}>
<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} providers={providers} editing={editing !== null} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} /> <CursorModelEditor draft={draft} 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>
<Modal open={caCommand !== null} title={t("安装本地 CA")} closeLabel={t("关闭")} submitLabel={t("打开终端")} onClose={() => setCaCommand(null)} onSubmit={openCaTerminal}> <ConfirmDialog open={caCommand !== null} title={t("安装本地 CA")} cancelLabel={t("关闭")} confirmLabel={t("打开终端")} onCancel={() => setCaCommand(null)} onConfirm={openCaTerminal}>
<div className={styles.editor}> <div className={styles.editor}><strong>{t("需要授权安装证书")}</strong><span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span><pre className={styles.command}>{caCommand}</pre></div>
<strong>{t("需要授权安装证书")}</strong> </ConfirmDialog>
<span>{t("安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。")}</span> <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>
<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 })); }
@@ -228,32 +303,6 @@ function parseObject(text: string, label: string): Record<string, unknown> {
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} }
function cursorModelInputs(draft: CursorModelDraft, editing: boolean) { function errorText(cause: unknown) {
const modelIds = editing return cause instanceof Error ? cause.message : String(cause);
? [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>;
} }
+4 -16
View File
@@ -30,16 +30,14 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
} }
export function HomePage() { export function HomePage() {
const { overview, busy, models, providers } = useAppStore(); const { overview, busy, models } = 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);
@@ -52,14 +50,13 @@ 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, appliedProviders]); }, [preset, customRange, overview, refreshVersion, appliedModels]);
const filteredOverview = rangeOverview ?? overview; const filteredOverview = rangeOverview ?? overview;
const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({ const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({
@@ -96,7 +93,6 @@ 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);
}; };
@@ -108,12 +104,7 @@ 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.endpoint_type), icon: iconFor(model.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[] = [
{ {
@@ -127,7 +118,7 @@ export function HomePage() {
{ {
key: "metrics", key: "metrics",
estimatedHeight: 130, estimatedHeight: 130,
content: <HomeMetrics data={metrics} />, content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
}, },
{ {
@@ -144,16 +135,13 @@ 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>
@@ -1,3 +0,0 @@
.pageContent {
height: 100%;
}
-101
View File
@@ -1,101 +0,0 @@
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,6 +41,24 @@
} }
} }
.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);
@@ -132,6 +150,11 @@
.portFields { .portFields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.importRow {
align-items: stretch;
flex-direction: column;
}
} }
.themeActions .selected { .themeActions .selected {
+55 -11
View File
@@ -1,12 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage } from "../api"; import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } 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";
@@ -30,11 +32,17 @@ 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()]).then(([nextStorage, nextProxy]) => { void Promise.all([api.statisticsStorage(), api.proxySettings(), api.tabSettings()]).then(([nextStorage, nextProxy, nextTab]) => {
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(() => {
@@ -114,6 +122,30 @@ 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"];
@@ -191,7 +223,19 @@ 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>
@@ -240,24 +284,24 @@ export function SettingsPage() {
</button> </button>
</div> </div>
</TitledCard> </TitledCard>
<Modal <ConfirmDialog
open={confirmClear} open={confirmClear}
title={t("确定要清理所有统计数据吗?")} title={t("确定要清理所有统计数据吗?")}
busy={clearing} busy={clearing}
closeLabel={t("取消")} cancelLabel={t("取消")}
submitLabel={t("确认清理")} confirmLabel={t("确认清理")}
onClose={() => setConfirmClear(false)} onCancel={() => setConfirmClear(false)}
onSubmit={() => void clearStorage()} onConfirm={() => void clearStorage()}
> >
<div className={styles.confirmContent}> <div className={styles.confirmContent}>
<small> <small>
{t( {t(
"所有调用记录和详细追踪数据都会被删除。供应商、模型、CA 和应用设置不会受到影响,此操作无法撤销。", "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
)} )}
</small> </small>
</div> </div>
</Modal> </ConfirmDialog>
</div> </div>
); );
return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 900, content }]} />; return <PageContent title={t("设置")} sections={[{ key: "settings", estimatedHeight: 1200, content }]} />;
} }
+48 -78
View File
@@ -1,11 +1,8 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings, type Provider, type ProviderInput, type ProviderSelection } from "../api"; import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } 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;
@@ -13,8 +10,6 @@ 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;
@@ -26,7 +21,6 @@ const savedTheme = (): ThemeId => {
}; };
let snapshot: AppSnapshot = { let snapshot: AppSnapshot = {
providers: [],
models: [], models: [],
calls: [], calls: [],
overview: { overview: {
@@ -48,8 +42,6 @@ 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,
@@ -81,8 +73,7 @@ export const appStore = {
async refresh() { async refresh() {
update({ busy: true, error: null }); update({ busy: true, error: null });
try { try {
const [providers, models, calls, overview, settings, ports, cursorHarness] = await Promise.all([ const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([
api.providers(),
api.models(), api.models(),
api.calls(), api.calls(),
api.overview(), api.overview(),
@@ -90,7 +81,7 @@ export const appStore = {
api.ports(), api.ports(),
api.cursorHarness(), api.cursorHarness(),
]); ]);
update({ providers, models, calls, overview, detailed: settings.detailed, ports, cursorHarness }); update({ 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 {
@@ -98,68 +89,6 @@ 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);
@@ -184,15 +113,26 @@ 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 createCursorModels(provider: ProviderSelection, models: ModelInput[]) { async createModels(models: ModelInput[]) {
update({ cursorBusy: true, error: null }); update({ cursorBusy: true, error: null });
try { try {
await api.createCursorModels(provider, models); const created = await api.createModels(models);
await appStore.refresh(); await appStore.refresh();
return true; return created;
} catch (cause) { } catch (cause) {
update({ error: cause instanceof Error ? cause.message : String(cause) }); update({ error: cause instanceof Error ? cause.message : String(cause) });
return false; return null;
} 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) {
@@ -206,6 +146,36 @@ 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: #E6E6E67A, list-hoverBackground: #f3f3f3,
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: 24px; $font-size-xxl: 20px;
$font-size-3xl: 30px; $font-size-3xl: 30px;
+130
View File
@@ -0,0 +1,130 @@
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];
+19 -6
View File
@@ -29,24 +29,37 @@ impl ModelAssets {
} }
pub fn ensure(cache_root: &Path) -> Result<Self> { pub fn ensure(cache_root: &Path) -> Result<Self> {
let client = reqwest::blocking::Client::builder()
.build()
.map_err(|error| Error::ModelAsset(error.to_string()))?;
Self::ensure_with_client(cache_root, &client)
}
pub fn ensure_with_client(
cache_root: &Path,
client: &reqwest::blocking::Client,
) -> Result<Self> {
let directory = cache_root.join("models/potion-code-16M-v2"); let directory = cache_root.join("models/potion-code-16M-v2");
fs::create_dir_all(&directory).map_err(|error| Error::io(&directory, error))?; fs::create_dir_all(&directory).map_err(|error| Error::io(&directory, error))?;
let model = Self::model_path(cache_root); let model = Self::model_path(cache_root);
let tokenizer = directory.join("tokenizer.json"); let tokenizer = directory.join("tokenizer.json");
ensure_asset(&model, MODEL_URL, MODEL_SHA256)?; ensure_asset(client, &model, MODEL_URL, MODEL_SHA256)?;
ensure_asset(&tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?; ensure_asset(client, &tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?;
Ok(Self { model, tokenizer }) Ok(Self { model, tokenizer })
} }
} }
fn ensure_asset(path: &Path, url: &str, expected: &str) -> Result<()> { fn ensure_asset(
client: &reqwest::blocking::Client,
path: &Path,
url: &str,
expected: &str,
) -> Result<()> {
if path.is_file() && digest(path)? == expected { if path.is_file() && digest(path)? == expected {
return Ok(()); return Ok(());
} }
let temporary = path.with_extension(format!("tmp-{}", std::process::id())); let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
let response = reqwest::blocking::Client::builder() let response = client
.build()
.map_err(|error| Error::ModelAsset(error.to_string()))?
.get(url) .get(url)
.send() .send()
.and_then(reqwest::blocking::Response::error_for_status) .and_then(reqwest::blocking::Response::error_for_status)
+11 -1
View File
@@ -35,12 +35,22 @@ static EMBEDDERS: LazyLock<Mutex<HashMap<PathBuf, Arc<StaticEmbedder>>>> =
impl SearchEngine { impl SearchEngine {
pub fn load_default(config: SembleConfig) -> Result<Self> { pub fn load_default(config: SembleConfig) -> Result<Self> {
let client = reqwest::blocking::Client::builder()
.build()
.map_err(|error| Error::ModelAsset(error.to_string()))?;
Self::load_default_with_client(config, &client)
}
pub fn load_default_with_client(
config: SembleConfig,
client: &reqwest::blocking::Client,
) -> Result<Self> {
let model_path = ModelAssets::model_path(&config.cache_dir); let model_path = ModelAssets::model_path(&config.cache_dir);
let cached = { EMBEDDERS.lock().get(&model_path).cloned() }; let cached = { EMBEDDERS.lock().get(&model_path).cloned() };
let embedder = if let Some(embedder) = cached { let embedder = if let Some(embedder) = cached {
embedder embedder
} else { } else {
let assets = ModelAssets::ensure(&config.cache_dir)?; let assets = ModelAssets::ensure_with_client(&config.cache_dir, client)?;
let embedder = Arc::new(StaticEmbedder::load(&assets.model, &assets.tokenizer)?); let embedder = Arc::new(StaticEmbedder::load(&assets.model, &assets.tokenizer)?);
EMBEDDERS EMBEDDERS
.lock() .lock()
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
function readOptions(args) {
const options = new Map();
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
const value = args[index + 1];
if (!name?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument near ${name ?? "end of command"}`);
}
options.set(name.slice(2), value);
}
return options;
}
function required(options, name) {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
export function normalizeTauriUpdate(manifest, release, repository, version) {
if (manifest.version !== version) {
throw new Error(
`updater manifest version ${manifest.version ?? "is missing"}; expected ${version}`,
);
}
if (release.tag_name !== `v${version}`) {
throw new Error(
`GitHub release tag ${release.tag_name ?? "is missing"}; expected v${version}`,
);
}
if (!manifest.platforms || typeof manifest.platforms !== "object") {
throw new Error("updater manifest has no platforms");
}
const assetsBySourceUrl = new Map();
const publicAssetUrls = new Set();
for (const asset of release.assets ?? []) {
if (!asset?.id || !asset?.name) continue;
const publicUrl = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(asset.name)}`;
assetsBySourceUrl.set(
`https://api.github.com/repos/${repository}/releases/assets/${asset.id}`,
publicUrl,
);
if (asset.browser_download_url) {
assetsBySourceUrl.set(asset.browser_download_url, publicUrl);
}
publicAssetUrls.add(publicUrl);
}
for (const [platform, entry] of Object.entries(manifest.platforms)) {
if (!entry?.signature || !entry?.url) {
throw new Error(`updater platform ${platform} is missing its URL or signature`);
}
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
if (!publicAssetUrls.has(publicUrl)) {
throw new Error(`updater platform ${platform} references an unknown release asset`);
}
entry.url = publicUrl;
}
return manifest;
}
async function main() {
const options = readOptions(process.argv.slice(2));
const manifestPath = resolve(required(options, "manifest"));
const releasePath = resolve(required(options, "release"));
const repository = required(options, "repository");
const version = required(options, "version").replace(/^v/, "");
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const release = JSON.parse(await readFile(releasePath, "utf8"));
const normalized = normalizeTauriUpdate(
manifest,
release,
repository,
version,
);
await writeFile(manifestPath, `${JSON.stringify(normalized, null, 2)}\n`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
+2 -1
View File
@@ -33,12 +33,13 @@ parking_lot = "0.12"
pem = "3" pem = "3"
prost = "0.13" prost = "0.13"
prost-types = "0.13" prost-types = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls", "socks", "stream", "system-proxy", "zstd"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "brotli", "deflate", "gzip", "json", "native-tls", "socks", "stream", "system-proxy", "zstd"] }
regex = "1" regex = "1"
rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] } rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] }
scraper = "0.24" scraper = "0.24"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
semble-core = { path = "../crates/semble-core" } semble-core = { path = "../crates/semble-core" }
sha1 = "0.10" sha1 = "0.10"
sha2 = "0.10" sha2 = "0.10"
@@ -0,0 +1,6 @@
-- Cursor may reuse one transport request id for multiple queued executions.
-- Keep that id as an association key while each local Run keeps its own identity.
ALTER TABLE runs ADD COLUMN cursor_request_id TEXT;
CREATE INDEX idx_runs_cursor_request_active
ON runs(cursor_request_id, status, created_at_ms DESC);
@@ -0,0 +1,197 @@
PRAGMA defer_foreign_keys = ON;
CREATE TABLE model_configs (
model_hash TEXT PRIMARY KEY,
sort_order INTEGER NOT NULL DEFAULT 0,
display_name TEXT NOT NULL,
model_type TEXT NOT NULL CHECK(model_type IN ('openai', 'anthropic')),
base_url TEXT NOT NULL,
use_full_url INTEGER NOT NULL DEFAULT 0 CHECK(use_full_url IN (0, 1)),
api_key TEXT NOT NULL,
tooltip_data TEXT NOT NULL,
model_id TEXT NOT NULL,
reasoning_effort TEXT,
openai_endpoint TEXT NOT NULL DEFAULT '',
openai_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(openai_extra_params_enabled IN (0, 1)),
openai_extra_params_json TEXT NOT NULL DEFAULT '{}',
custom_headers_enabled INTEGER NOT NULL DEFAULT 0 CHECK(custom_headers_enabled IN (0, 1)),
custom_headers_json TEXT NOT NULL DEFAULT '{}',
anthropic_extra_params_enabled INTEGER NOT NULL DEFAULT 0 CHECK(anthropic_extra_params_enabled IN (0, 1)),
anthropic_extra_params_json TEXT NOT NULL DEFAULT '{}',
context_window_tokens INTEGER,
max_completion_tokens INTEGER,
anthropic_max_tokens INTEGER,
anthropic_thinking_effort TEXT,
thinking_budget_tokens INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
INSERT INTO model_configs (
model_hash,
sort_order,
display_name,
model_type,
base_url,
use_full_url,
api_key,
tooltip_data,
model_id,
reasoning_effort,
openai_endpoint,
openai_extra_params_enabled,
openai_extra_params_json,
custom_headers_enabled,
custom_headers_json,
anthropic_extra_params_enabled,
anthropic_extra_params_json,
context_window_tokens,
max_completion_tokens,
anthropic_max_tokens,
anthropic_thinking_effort,
thinking_budget_tokens,
created_at_ms,
updated_at_ms
)
SELECT
model.model_hash,
model.sort_order,
model.display_name,
CASE model.endpoint_type WHEN 'anthropic' THEN 'anthropic' ELSE 'openai' END,
CASE
WHEN model.request_url = '' THEN endpoint.base_url
WHEN model.request_url LIKE 'http://%' OR model.request_url LIKE 'https://%' THEN model.request_url
ELSE replace(rtrim(endpoint.base_url, '/') || '/' || ltrim(model.request_url, '/'), '/v1/v1/', '/v1/')
END,
CASE WHEN model.request_url = '' THEN 0 ELSE 1 END,
endpoint.api_key,
model.display_name,
model.model_id,
CASE
WHEN model.endpoint_type != 'anthropic' AND model.reasoning_enabled = 1
THEN COALESCE(NULLIF(trim(model.reasoning_effort), ''), 'medium')
ELSE NULL
END,
CASE model.endpoint_type
WHEN 'openai-responses' THEN '/v1/responses'
WHEN 'openai-chat' THEN '/v1/chat/completions'
ELSE ''
END,
CASE WHEN model.endpoint_type != 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
CASE WHEN model.endpoint_type != 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
CASE WHEN endpoint.custom_headers_json != '{}' THEN 1 ELSE 0 END,
endpoint.custom_headers_json,
CASE WHEN model.endpoint_type = 'anthropic' AND endpoint.extra_params_json != '{}' THEN 1 ELSE 0 END,
CASE WHEN model.endpoint_type = 'anthropic' THEN endpoint.extra_params_json ELSE '{}' END,
model.context_window_tokens,
CASE WHEN model.endpoint_type != 'anthropic' THEN model.max_output_tokens ELSE NULL END,
CASE WHEN model.endpoint_type = 'anthropic' THEN model.max_output_tokens ELSE NULL END,
CASE WHEN model.endpoint_type = 'anthropic' THEN 'xhigh' ELSE NULL END,
NULL,
model.created_at_ms,
model.updated_at_ms
FROM provider_models AS model
JOIN provider_endpoints AS endpoint ON endpoint.provider_id = model.provider_id;
CREATE TABLE llm_calls_new (
call_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
provider_call_index INTEGER NOT NULL,
model_hash TEXT,
provider_type TEXT NOT NULL,
provider_url TEXT NOT NULL,
request_type TEXT NOT NULL,
request_url TEXT NOT NULL,
model_id TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL,
finish_reason TEXT,
created_at_ms INTEGER NOT NULL,
request_started_at_ms INTEGER,
response_headers_at_ms INTEGER,
first_event_at_ms INTEGER,
first_text_at_ms INTEGER,
finished_at_ms INTEGER,
queue_ms INTEGER,
ttfb_ms INTEGER,
ttft_ms INTEGER,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
usage_json TEXT,
message_count INTEGER NOT NULL,
tool_count INTEGER NOT NULL,
request_bytes INTEGER,
response_bytes INTEGER NOT NULL DEFAULT 0,
stream_event_count INTEGER NOT NULL DEFAULT 0,
http_status INTEGER,
error_kind TEXT,
error_message TEXT,
detailed INTEGER NOT NULL,
reasoning_effort TEXT,
fast INTEGER NOT NULL DEFAULT 0 CHECK (fast IN (0, 1)),
FOREIGN KEY(model_hash) REFERENCES model_configs(model_hash)
);
INSERT INTO llm_calls_new (
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
)
SELECT
call_id, run_id, conversation_id, provider_call_index, model_hash, provider_type,
provider_url, request_type, request_url, model_id, display_name, status, finish_reason,
created_at_ms, request_started_at_ms, response_headers_at_ms, first_event_at_ms,
first_text_at_ms, finished_at_ms, queue_ms, ttfb_ms, ttft_ms, duration_ms,
input_tokens, output_tokens, total_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, usage_json, message_count, tool_count, request_bytes, response_bytes,
stream_event_count, http_status, error_kind, error_message, detailed, reasoning_effort, fast
FROM llm_calls;
CREATE TABLE llm_call_requests_new (
call_id TEXT PRIMARY KEY,
headers_json TEXT NOT NULL,
body_json TEXT NOT NULL,
byte_count INTEGER NOT NULL,
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_requests_new(call_id, headers_json, body_json, byte_count)
SELECT call_id, headers_json, body_json, byte_count FROM llm_call_requests;
CREATE TABLE llm_call_response_chunks_new (
call_id TEXT NOT NULL,
seq INTEGER NOT NULL,
received_offset_ms INTEGER NOT NULL,
data BLOB NOT NULL,
byte_count INTEGER NOT NULL,
PRIMARY KEY(call_id, seq),
FOREIGN KEY(call_id) REFERENCES llm_calls_new(call_id) ON DELETE CASCADE
);
INSERT INTO llm_call_response_chunks_new(call_id, seq, received_offset_ms, data, byte_count)
SELECT call_id, seq, received_offset_ms, data, byte_count FROM llm_call_response_chunks;
DROP TABLE llm_call_requests;
DROP TABLE llm_call_response_chunks;
DROP TABLE llm_calls;
DROP TABLE provider_models;
DROP TABLE provider_endpoints;
ALTER TABLE llm_calls_new RENAME TO llm_calls;
ALTER TABLE llm_call_requests_new RENAME TO llm_call_requests;
ALTER TABLE llm_call_response_chunks_new RENAME TO llm_call_response_chunks;
CREATE INDEX model_configs_sort ON model_configs(sort_order, display_name);
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
File diff suppressed because one or more lines are too long
+4
View File
@@ -84,6 +84,10 @@ impl App {
self.harness.clone() self.harness.clone()
} }
pub fn store(&self) -> Store {
self.store.clone()
}
pub async fn serve(self) -> Result<()> { pub async fn serve(self) -> Result<()> {
let listener = self.bind().await?; let listener = self.bind().await?;
let shutdown = CancellationToken::new(); let shutdown = CancellationToken::new();
+10 -1
View File
@@ -1,10 +1,19 @@
use tokio::sync::oneshot;
use crate::model::{CanonicalMessage, RuntimeEvent, ToolResult}; use crate::model::{CanonicalMessage, RuntimeEvent, ToolResult};
#[derive(Clone, Debug, PartialEq)] #[derive(Debug)]
pub struct MessageInsertion {
pub messages: Vec<CanonicalMessage>,
pub delivered: oneshot::Sender<()>,
}
#[derive(Debug)]
pub enum ClientCommand { pub enum ClientCommand {
ToolResult(ToolResult), ToolResult(ToolResult),
RuntimeMessage(CanonicalMessage), RuntimeMessage(CanonicalMessage),
RuntimeEvent(RuntimeEvent), RuntimeEvent(RuntimeEvent),
InsertMessages(MessageInsertion),
ClientClosed { error: String }, ClientClosed { error: String },
Cancel, Cancel,
} }
+10
View File
@@ -7,6 +7,8 @@ use crate::{Error, Result};
const DATA_DIR_NAME: &str = ".cursor-byok-v3"; const DATA_DIR_NAME: &str = ".cursor-byok-v3";
const DATABASE_FILE_NAME: &str = "cursor-byok.db"; const DATABASE_FILE_NAME: &str = "cursor-byok.db";
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
pub fn managed_data_dir() -> Result<PathBuf> { pub fn managed_data_dir() -> Result<PathBuf> {
let home_dir = dirs::home_dir() let home_dir = dirs::home_dir()
@@ -18,6 +20,14 @@ pub fn managed_data_dir() -> Result<PathBuf> {
Ok(data_dir) Ok(data_dir)
} }
pub fn v0049_config_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir()
.ok_or_else(|| Error::Config("cannot resolve user home directory".into()))?;
Ok(home_dir
.join(V0049_DATA_DIR_NAME)
.join(V0049_CONFIG_FILE_NAME))
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProviderKind { pub enum ProviderKind {
OpenAiChat, OpenAiChat,
-72
View File
@@ -1,72 +0,0 @@
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use crate::{
model::{ProviderEndpoint, ProviderEndpointInput, ProviderModel, ProviderModelInput},
Result,
};
use super::{ControlService, DiscoveredModels};
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProviderSelection {
Existing { provider_id: i64 },
New { input: ProviderEndpointInput },
}
#[derive(Debug, Deserialize)]
pub struct CreateCursorModels {
pub provider: ProviderSelection,
pub models: Vec<ProviderModelInput>,
}
#[derive(Debug, Serialize)]
pub struct CreatedCursorModels {
pub provider: ProviderEndpoint,
pub models: Vec<ProviderModel>,
}
#[derive(Debug, Deserialize)]
pub struct DiscoverCursorModels {
pub provider: ProviderSelection,
}
pub async fn create(
State(service): State<ControlService>,
Json(input): Json<CreateCursorModels>,
) -> Result<(StatusCode, Json<CreatedCursorModels>)> {
let (provider, models) = match input.provider {
ProviderSelection::Existing { provider_id } => {
let provider = service
.providers()
.await?
.into_iter()
.find(|provider| provider.provider_id == provider_id)
.ok_or_else(|| crate::Error::RunNotFound(format!("provider {provider_id}")))?;
let models = service.save_models(provider_id, &input.models).await?;
(provider, models)
}
ProviderSelection::New { input: provider } => {
service
.create_provider_with_models(&provider, &input.models)
.await?
}
};
Ok((
StatusCode::CREATED,
Json(CreatedCursorModels { provider, models }),
))
}
pub async fn discover(
State(service): State<ControlService>,
Json(input): Json<DiscoverCursorModels>,
) -> Result<Json<DiscoveredModels>> {
match input.provider {
ProviderSelection::Existing { provider_id } => {
Ok(Json(service.discover_models(provider_id).await?))
}
ProviderSelection::New { input } => Ok(Json(service.discover_input(&input).await?)),
}
}
+16 -29
View File
@@ -1,10 +1,8 @@
mod ads; mod ads;
mod calls; mod calls;
mod cursor_models;
mod harness; mod harness;
mod models; mod models;
mod overview; mod overview;
mod providers;
mod service; mod service;
mod settings; mod settings;
@@ -22,8 +20,8 @@ use tower_http::{
use url::{Host, Url}; use url::{Host, Url};
pub use service::{ pub use service::{
CallDetail, CallSummary, ControlService, DiscoveredModels, ModelConnectivityResult, CallDetail, CallSummary, ControlService, DiscoveredModels, LegacyModelImportPreview,
ObservabilitySettings, LegacyModelImportResult, ModelConnectivityResult, ModelDiscoveryInput, ObservabilitySettings,
}; };
pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router { pub fn web_router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
@@ -117,30 +115,23 @@ pub fn api_router(service: ControlService) -> Router {
post(ads::dismiss), post(ads::dismiss),
) )
.route( .route(
"/__byok-api__/api/providers", "/__byok-api__/api/models",
get(providers::list).post(providers::create), get(models::list).post(models::create),
) )
.route("/__byok-api__/api/models/discover", post(models::discover))
.route( .route(
"/__byok-api__/api/providers/{provider_id}", "/__byok-api__/api/models/import-v0049",
put(providers::update).delete(providers::remove), get(models::preview_v0049).post(models::import_v0049),
) )
.route( .route("/__byok-api__/api/models/order", put(models::reorder))
"/__byok-api__/api/providers/{provider_id}/models/discover",
post(models::discover),
)
.route(
"/__byok-api__/api/providers/{provider_id}/models",
post(models::save),
)
.route("/__byok-api__/api/models", get(models::list))
.route("/__byok-api__/api/overview", get(overview::get)) .route("/__byok-api__/api/overview", get(overview::get))
.route( .route(
"/__byok-api__/api/models/{model_hash}", "/__byok-api__/api/models/{model_hash}",
put(models::update).delete(models::remove), put(models::update).delete(models::remove),
) )
.route( .route(
"/__byok-api__/api/models/{model_hash}/test", "/__byok-api__/api/models/{model_hash}/test/{test_id}",
post(models::test), post(models::test).delete(models::cancel),
) )
.route("/__byok-api__/api/llm-calls", get(calls::list)) .route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail)) .route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
@@ -164,6 +155,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/settings/tab", "/__byok-api__/api/settings/tab",
get(settings::get_tab).put(settings::update_tab), get(settings::get_tab).put(settings::update_tab),
) )
.route(
"/__byok-api__/api/settings/desktop",
get(settings::get_desktop).put(settings::update_desktop),
)
.route( .route(
"/__byok-api__/api/harness/cursor/status", "/__byok-api__/api/harness/cursor/status",
get(harness::status), get(harness::status),
@@ -176,14 +171,6 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/harness/cursor/enabled", "/__byok-api__/api/harness/cursor/enabled",
put(harness::set_enabled), put(harness::set_enabled),
) )
.route(
"/__byok-api__/api/harness/cursor/models",
post(cursor_models::create),
)
.route(
"/__byok-api__/api/harness/cursor/models/discover",
post(cursor_models::discover),
)
.with_state(service) .with_state(service)
.layer(desktop_cors()) .layer(desktop_cors())
} }
@@ -261,7 +248,7 @@ mod tests {
.clone() .clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/__byok-api__/api/providers") .uri("/__byok-api__/api/models")
.header(header::ORIGIN, "tauri://localhost") .header(header::ORIGIN, "tauri://localhost")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
@@ -289,7 +276,7 @@ mod tests {
let response = router let response = router
.oneshot( .oneshot(
Request::builder() Request::builder()
.uri("/api/providers") .uri("/api/models")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
+48 -14
View File
@@ -6,32 +6,46 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
model::{ProviderModel, ProviderModelInput}, model::{ModelConfig, ModelConfigInput},
Result, Result,
}; };
use super::{ControlService, DiscoveredModels, ModelConnectivityResult}; use super::{
ControlService, DiscoveredModels, LegacyModelImportPreview, LegacyModelImportResult,
ModelConnectivityResult, ModelDiscoveryInput,
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SaveModels { pub struct SaveModels {
pub models: Vec<ProviderModelInput>, pub models: Vec<ModelConfigInput>,
} }
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> { #[derive(Deserialize)]
pub struct ModelOrder {
pub model_hashes: Vec<String>,
}
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ModelConfig>>> {
Ok(Json(service.models().await?)) Ok(Json(service.models().await?))
} }
pub async fn save( pub async fn create(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<SaveModels>, Json(input): Json<SaveModels>,
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> { ) -> Result<(StatusCode, Json<Vec<ModelConfig>>)> {
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(service.save_models(provider_id, &input.models).await?), Json(service.create_models(&input.models).await?),
)) ))
} }
pub async fn reorder(
State(service): State<ControlService>,
Json(input): Json<ModelOrder>,
) -> Result<Json<Vec<ModelConfig>>> {
Ok(Json(service.reorder_models(&input.model_hashes).await?))
}
pub async fn remove( pub async fn remove(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(model_hash): Path<String>, Path(model_hash): Path<String>,
@@ -43,21 +57,41 @@ pub async fn remove(
pub async fn update( pub async fn update(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(model_hash): Path<String>, Path(model_hash): Path<String>,
Json(input): Json<ProviderModelInput>, Json(input): Json<ModelConfigInput>,
) -> Result<Json<ProviderModel>> { ) -> Result<Json<ModelConfig>> {
Ok(Json(service.update_model(&model_hash, &input).await?)) Ok(Json(service.update_model(&model_hash, &input).await?))
} }
pub async fn test( pub async fn test(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(model_hash): Path<String>, Path((model_hash, test_id)): Path<(String, String)>,
) -> Result<Json<ModelConnectivityResult>> { ) -> Result<Json<ModelConnectivityResult>> {
Ok(Json(service.test_model(&model_hash).await?)) Ok(Json(service.test_model(&model_hash, &test_id).await?))
}
pub async fn cancel(
State(service): State<ControlService>,
Path((_model_hash, test_id)): Path<(String, String)>,
) -> Result<StatusCode> {
service.cancel_model_test(&test_id);
Ok(StatusCode::NO_CONTENT)
} }
pub async fn discover( pub async fn discover(
State(service): State<ControlService>, State(service): State<ControlService>,
Path(provider_id): Path<i64>, Json(input): Json<ModelDiscoveryInput>,
) -> Result<Json<DiscoveredModels>> { ) -> Result<Json<DiscoveredModels>> {
Ok(Json(service.discover_models(provider_id).await?)) Ok(Json(service.discover_models(&input).await?))
}
pub async fn import_v0049(
State(service): State<ControlService>,
) -> Result<Json<LegacyModelImportResult>> {
Ok(Json(service.import_v0049_models().await?))
}
pub async fn preview_v0049(
State(service): State<ControlService>,
) -> Result<Json<LegacyModelImportPreview>> {
Ok(Json(service.preview_v0049_models().await?))
} }
+1 -7
View File
@@ -15,7 +15,6 @@ pub struct OverviewRange {
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
model_hashes: Option<String>, model_hashes: Option<String>,
provider_ids: Option<String>,
} }
pub async fn get( pub async fn get(
@@ -24,12 +23,7 @@ pub async fn get(
) -> Result<Json<Overview>> { ) -> Result<Json<Overview>> {
Ok(Json( Ok(Json(
service service
.overview( .overview(range.start_ms, range.end_ms, range.model_hashes.as_deref())
range.start_ms,
range.end_ms,
range.model_hashes.as_deref(),
range.provider_ids.as_deref(),
)
.await?, .await?,
)) ))
} }
-42
View File
@@ -1,42 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use crate::{
model::{ProviderEndpoint, ProviderEndpointInput},
Result,
};
use super::ControlService;
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
Ok(Json(service.providers().await?))
}
pub async fn create(
State(service): State<ControlService>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
Ok((
StatusCode::CREATED,
Json(service.create_provider(&input).await?),
))
}
pub async fn update(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
Json(input): Json<ProviderEndpointInput>,
) -> Result<Json<ProviderEndpoint>> {
Ok(Json(service.update_provider(provider_id, &input).await?))
}
pub async fn remove(
State(service): State<ControlService>,
Path(provider_id): Path<i64>,
) -> Result<StatusCode> {
service.delete_provider(provider_id).await?;
Ok(StatusCode::NO_CONTENT)
}
+469 -140
View File
@@ -1,4 +1,8 @@
use std::{collections::BTreeSet, sync::Arc, time::Instant}; use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
time::Instant,
};
use base64::{engine::general_purpose::STANDARD, Engine}; use base64::{engine::general_purpose::STANDARD, Engine};
use futures_util::StreamExt; use futures_util::StreamExt;
@@ -16,13 +20,14 @@ use crate::{
harness::CursorHarness, harness::CursorHarness,
model::{ model::{
ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest, ContentPart, CursorRunTraceArtifact, CursorRunTraceSummary, LlmCallRequest,
LlmCallResponseChunk, LlmCallSummary, ModelInvocation, ModelRequest, ModelSpec, Overview, LlmCallResponseChunk, LlmCallSummary, ModelConfig, ModelConfigInput, ModelInvocation,
ProjectedContent, ProjectedMessage, PromptSpec, ProviderEndpoint, ProviderEndpointInput, ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage,
ProviderModel, ProviderModelInput, ProviderType, Role, PromptSpec, ProviderType, Role,
}, },
provider::{ModelEvent, Provider}, provider::{ModelEvent, Provider},
store::{ store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, TabSettings, DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store,
TabSettings,
}, },
Error, Result, Error, Result,
}; };
@@ -32,6 +37,7 @@ pub struct ControlService {
store: Store, store: Store,
cursor_harness: CursorHarness, cursor_harness: CursorHarness,
provider: Arc<dyn Provider>, provider: Arc<dyn Provider>,
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -39,6 +45,53 @@ pub struct DiscoveredModels {
pub models: Vec<String>, pub models: Vec<String>,
} }
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportResult {
pub imported: usize,
pub skipped: usize,
pub total: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportPreview {
pub source: String,
pub total: usize,
pub new_models: usize,
pub existing_models: usize,
pub models: Vec<LegacyModelImportPreviewItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct LegacyModelImportPreviewItem {
pub model_hash: String,
pub display_name: String,
pub model_id: String,
#[serde(rename = "type")]
pub model_type: ModelType,
pub existing: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ModelDiscoveryInput {
#[serde(rename = "type")]
pub model_type: ModelType,
pub base_url: String,
pub api_key: String,
#[serde(default)]
pub custom_headers_enabled: bool,
#[serde(default = "empty_json_object")]
pub custom_headers: serde_json::Value,
}
fn empty_json_object() -> serde_json::Value {
serde_json::json!({})
}
fn empty_json_object_ref() -> &'static serde_json::Value {
static EMPTY: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
EMPTY.get_or_init(empty_json_object)
}
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct ModelConnectivityResult { pub struct ModelConnectivityResult {
pub duration_ms: u64, pub duration_ms: u64,
@@ -94,6 +147,7 @@ impl ControlService {
cursor_harness: CursorHarness::new(store.clone())?, cursor_harness: CursorHarness::new(store.clone())?,
store, store,
provider, provider,
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
}) })
} }
@@ -162,28 +216,8 @@ impl ControlService {
Ok(()) Ok(())
} }
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> { pub async fn models(&self) -> Result<Vec<ModelConfig>> {
self.store.providers().await self.store.models().await
}
pub async fn create_provider(&self, input: &ProviderEndpointInput) -> Result<ProviderEndpoint> {
self.store.create_provider(input).await
}
pub async fn update_provider(
&self,
provider_id: i64,
input: &ProviderEndpointInput,
) -> Result<ProviderEndpoint> {
self.store.update_provider(provider_id, input).await
}
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
self.store.delete_provider(provider_id).await
}
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
self.store.provider_models(false).await
} }
pub async fn overview( pub async fn overview(
@@ -191,58 +225,86 @@ impl ControlService {
start_ms: Option<i64>, start_ms: Option<i64>,
end_ms: Option<i64>, end_ms: Option<i64>,
model_hashes: Option<&str>, model_hashes: Option<&str>,
provider_ids: Option<&str>,
) -> Result<Overview> { ) -> Result<Overview> {
self.store self.store.overview(start_ms, end_ms, model_hashes).await
.overview(start_ms, end_ms, model_hashes, provider_ids)
.await
} }
pub async fn save_models( pub async fn create_models(&self, models: &[ModelConfigInput]) -> Result<Vec<ModelConfig>> {
&self, self.store.create_models(models).await
provider_id: i64, }
models: &[ProviderModelInput],
) -> Result<Vec<ProviderModel>> { pub async fn reorder_models(&self, model_hashes: &[String]) -> Result<Vec<ModelConfig>> {
self.store.save_provider_models(provider_id, models).await self.store.reorder_models(model_hashes).await
} }
pub async fn delete_model(&self, model_hash: &str) -> Result<()> { pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
self.store.delete_provider_model(model_hash).await self.store.delete_model(model_hash).await
} }
pub async fn update_model( pub async fn update_model(
&self, &self,
model_hash: &str, model_hash: &str,
input: &ProviderModelInput, input: &ModelConfigInput,
) -> Result<ProviderModel> { ) -> Result<ModelConfig> {
self.store.update_provider_model(model_hash, input).await self.store.update_model(model_hash, input).await
} }
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> { pub async fn test_model(
&self,
model_hash: &str,
test_id: &str,
) -> Result<ModelConnectivityResult> {
let cancellation = CancellationToken::new();
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(|| cancellation.clone())
.clone()
};
let result = self.run_model_test(model_hash, cancellation).await;
self.model_tests
.lock()
.expect("model test registry mutex poisoned")
.remove(test_id);
result
}
pub fn cancel_model_test(&self, test_id: &str) {
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests.entry(test_id.to_owned()).or_default().clone()
};
cancellation.cancel();
}
async fn run_model_test(
&self,
model_hash: &str,
cancellation: CancellationToken,
) -> Result<ModelConnectivityResult> {
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation."; const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
let configured = self let configured = self
.store .store
.provider_model(model_hash) .model(model_hash)
.await? .await?
.ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?; .ok_or_else(|| Error::RunNotFound(format!("model {model_hash}")))?;
let mut model = ModelSpec::new(model_hash); let mut model = ModelSpec::new(model_hash);
if configured.reasoning_enabled { configured.configure(&mut model);
model.reasoning.enabled = true; model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
model.reasoning.effort = Some( let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
configured
.reasoning_effort
.filter(|effort| !effort.trim().is_empty())
.unwrap_or_else(|| "medium".into()),
);
}
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
let call_id = test_id.clone();
let invocation = ModelInvocation { let invocation = ModelInvocation {
call_id: test_id.clone(), call_id: call_id.clone(),
run_id: test_id.clone(), run_id: call_id.clone(),
conversation_id: test_id, conversation_id: call_id.clone(),
provider_call_index: 0, provider_call_index: 0,
request: ModelRequest { request: ModelRequest {
prompt: PromptSpec { prompt: PromptSpec {
@@ -259,7 +321,6 @@ impl ControlService {
}], }],
}, },
}; };
let cancellation = CancellationToken::new();
let started = Instant::now(); let started = Instant::now();
let mut first_text_at = None; let mut first_text_at = None;
let mut output_tokens = None; let mut output_tokens = None;
@@ -287,6 +348,9 @@ impl ControlService {
_ => {} _ => {}
} }
} }
if cancellation.is_cancelled() {
return Err(Error::Cancelled);
}
if !finished { if !finished {
return Err(Error::Protocol( return Err(Error::Protocol(
"provider stream ended without Done during connectivity test".into(), "provider stream ended without Done during connectivity test".into(),
@@ -316,6 +380,11 @@ impl ControlService {
} }
let elapsed = started.elapsed(); let elapsed = started.elapsed();
let output = output.trim().to_string(); let output = output.trim().to_string();
if first_text_at.is_none() {
return Err(Error::Provider(
"model connectivity test received no text output".into(),
));
}
let tokens_estimated = output_tokens.is_none(); let tokens_estimated = output_tokens.is_none();
let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output)); let output_tokens = output_tokens.unwrap_or_else(|| estimate_output_tokens(&output));
Ok(ModelConnectivityResult { Ok(ModelConnectivityResult {
@@ -337,44 +406,58 @@ impl ControlService {
}) })
} }
pub async fn create_provider_with_models( pub async fn discover_models(&self, input: &ModelDiscoveryInput) -> Result<DiscoveredModels> {
&self,
provider: &ProviderEndpointInput,
models: &[ProviderModelInput],
) -> Result<(ProviderEndpoint, Vec<ProviderModel>)> {
self.store
.create_provider_with_models(provider, models)
.await
}
pub async fn discover_input(&self, input: &ProviderEndpointInput) -> Result<DiscoveredModels> {
let client = crate::network::client(&self.store).await?; let client = crate::network::client(&self.store).await?;
let base_url = crate::model::normalize_base_url(&input.base_url)?; let base_url = crate::model::normalize_request_url(&input.base_url)?;
discover_provider_models( discover_models_from_endpoint(
&client, &client,
input.provider_type, match input.model_type {
ModelType::OpenAi => ProviderType::OpenAiResponses,
ModelType::Anthropic => ProviderType::Anthropic,
},
&base_url, &base_url,
input.api_key.as_deref().unwrap_or_default(), &input.api_key,
&input.custom_headers, if input.custom_headers_enabled {
&input.custom_headers
} else {
empty_json_object_ref()
},
) )
.await .await
} }
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> { pub async fn import_v0049_models(&self) -> Result<LegacyModelImportResult> {
let client = crate::network::client(&self.store).await?; let path = crate::config::v0049_config_path()?;
let provider = self let outcome = self.store.import_v0049_model_config(&path).await?;
.store Ok(LegacyModelImportResult {
.provider(provider_id) imported: outcome.imported,
.await? skipped: outcome.skipped,
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?; total: outcome.total,
discover_provider_models( })
&client, }
provider.endpoint.provider_type,
&provider.endpoint.base_url, pub async fn preview_v0049_models(&self) -> Result<LegacyModelImportPreview> {
provider.endpoint.api_key.as_deref().unwrap_or_default(), let path = crate::config::v0049_config_path()?;
&provider.custom_headers, let plan = self.store.preview_v0049_model_config(&path).await?;
) let total = plan.models.len();
.await let existing_models = plan.models.iter().filter(|model| model.existing).count();
Ok(LegacyModelImportPreview {
source: path.display().to_string(),
total,
new_models: total - existing_models,
existing_models,
models: plan
.models
.into_iter()
.map(|model| LegacyModelImportPreviewItem {
model_hash: model.model_hash,
display_name: model.input.display_name,
model_id: model.input.model_id,
model_type: model.input.model_type,
existing: model.existing,
})
.collect(),
})
} }
pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> { pub async fn calls(&self, limit: i64) -> Result<Vec<CallSummary>> {
@@ -497,6 +580,14 @@ impl ControlService {
pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> { pub async fn set_tab_settings(&self, settings: TabSettings) -> Result<TabSettings> {
self.cursor_harness.set_tab_settings(settings).await self.cursor_harness.set_tab_settings(settings).await
} }
pub async fn desktop_settings(&self) -> Result<DesktopSettings> {
self.store.desktop_settings().await
}
pub async fn set_desktop_settings(&self, settings: DesktopSettings) -> Result<()> {
self.store.set_desktop_settings(settings).await
}
} }
fn official_call(trace: CursorRunTraceSummary) -> CallSummary { fn official_call(trace: CursorRunTraceSummary) -> CallSummary {
@@ -586,7 +677,7 @@ fn readable_utf8(data: &[u8]) -> Option<&str> {
.then_some(value) .then_some(value)
} }
async fn discover_provider_models( async fn discover_models_from_endpoint(
client: &reqwest::Client, client: &reqwest::Client,
provider_type: ProviderType, provider_type: ProviderType,
base_url: &str, base_url: &str,
@@ -608,25 +699,101 @@ async fn discover_provider_models(
fn model_discovery_url(base_url: &str) -> Result<Url> { fn model_discovery_url(base_url: &str) -> Result<Url> {
let mut url = Url::parse(base_url) let mut url = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid provider base URL: {error}")))?; .map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
if url.host_str().is_none() { if url.host_str().is_none() {
return Err(Error::Config( return Err(Error::Config(
"provider base URL must contain a host".into(), "model request URL must contain a host".into(),
)); ));
} }
url.set_path("/v1/models"); // 在现有路径上追加,而不是整段替换:多数编程套餐的 API 挂在子路径下
// (/api/anthropic、/coding、/api/paas/v4 等),直接 set_path("/v1/models")
// 会把这些前缀吃掉,发现请求必然 404
let path = url.path().trim_end_matches('/');
let last = path.rsplit('/').next().unwrap_or("");
let versioned = last.len() > 1
&& last.starts_with('v')
&& last[1..].bytes().all(|byte| byte.is_ascii_digit());
let new_path = if let Some(parent) = path.strip_suffix("/chat/completions") {
// 完整请求 URL:剥掉端点段(chat/completions 是两段),换成 models
format!("{parent}/models")
} else if let Some(parent) = path
.strip_suffix("/responses")
.or_else(|| path.strip_suffix("/messages"))
.or_else(|| path.strip_suffix("/completions"))
{
format!("{parent}/models")
} else if path.is_empty() {
"/v1/models".to_string()
} else if versioned {
// 已带版本段(/v1、/api/v3、/api/paas/v4):只补 models
format!("{path}/models")
} else {
format!("{path}/v1/models")
};
url.set_path(&new_path);
url.set_query(None); url.set_query(None);
url.set_fragment(None); url.set_fragment(None);
Ok(url) Ok(url)
} }
fn model_discovery_urls(base_url: &str) -> Result<Vec<Url>> {
let mut configured = Url::parse(base_url)
.map_err(|error| Error::Config(format!("invalid model request URL: {error}")))?;
let path = configured.path().trim_end_matches('/');
let tail = path.rsplit('/').next().unwrap_or_default();
if matches!(tail.to_ascii_lowercase().as_str(), "model" | "models") {
configured.set_query(None);
configured.set_fragment(None);
return Ok(vec![configured]);
}
let primary = model_discovery_url(base_url)?;
let versioned = tail.len() > 1
&& tail.starts_with('v')
&& tail[1..].bytes().all(|byte| byte.is_ascii_digit());
let complete_request_url = [
"/chat/completions",
"/responses",
"/messages",
"/completions",
]
.iter()
.any(|suffix| path.to_ascii_lowercase().ends_with(suffix));
if versioned || complete_request_url {
return Ok(vec![primary]);
}
let Some(prefix) = primary.path().strip_suffix("/v1/models") else {
return Ok(vec![primary]);
};
let mut fallback = primary.clone();
fallback.set_path(&format!("{prefix}/models"));
Ok(vec![primary, fallback])
}
async fn openai_models( async fn openai_models(
client: &reqwest::Client, client: &reqwest::Client,
base_url: &str, base_url: &str,
api_key: &str, api_key: &str,
custom_headers: &serde_json::Value, custom_headers: &serde_json::Value,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let mut request = client.get(model_discovery_url(base_url)?); let mut last_error = None;
for url in model_discovery_urls(base_url)? {
match openai_models_at(client, url, api_key, custom_headers).await {
Ok(models) => return Ok(models),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| Error::Provider("no model discovery URL available".into())))
}
async fn openai_models_at(
client: &reqwest::Client,
url: Url,
api_key: &str,
custom_headers: &serde_json::Value,
) -> Result<Vec<String>> {
let mut request = client.get(url);
if !api_key.is_empty() { if !api_key.is_empty() {
request = request.bearer_auth(api_key); request = request.bearer_auth(api_key);
} }
@@ -648,12 +815,28 @@ async fn anthropic_models(
base_url: &str, base_url: &str,
api_key: &str, api_key: &str,
custom_headers: &serde_json::Value, custom_headers: &serde_json::Value,
) -> Result<Vec<String>> {
let mut last_error = None;
for url in model_discovery_urls(base_url)? {
match anthropic_models_at(client, url, api_key, custom_headers).await {
Ok(models) => return Ok(models),
Err(error) => last_error = Some(error),
}
}
Err(last_error.unwrap_or_else(|| Error::Provider("no model discovery URL available".into())))
}
async fn anthropic_models_at(
client: &reqwest::Client,
url: Url,
api_key: &str,
custom_headers: &serde_json::Value,
) -> Result<Vec<String>> { ) -> Result<Vec<String>> {
let mut after_id = None::<String>; let mut after_id = None::<String>;
let mut found = BTreeSet::new(); let mut found = BTreeSet::new();
loop { loop {
let mut request = client let mut request = client
.get(model_discovery_url(base_url)?) .get(url.clone())
.query(&[("limit", "100")]) .query(&[("limit", "100")])
.header("anthropic-version", "2023-06-01"); .header("anthropic-version", "2023-06-01");
if !api_key.is_empty() { if !api_key.is_empty() {
@@ -747,20 +930,112 @@ mod tests {
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
model::{ model::{ModelConfig, ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
ModelInvocation, ProjectedContent, ProviderEndpointInput, ProviderModelInput,
ProviderType,
},
provider::{FinishReason, ModelEvent, Provider, ProviderStream}, provider::{FinishReason, ModelEvent, Provider, ProviderStream},
store::Store, store::Store,
}; };
use super::ControlService; use super::{model_discovery_url, model_discovery_urls, ControlService};
#[test]
fn model_discovery_url_appends_to_path() {
let cases = [
(
"https://api.deepseek.com",
"https://api.deepseek.com/v1/models",
),
(
"https://open.bigmodel.cn/api/anthropic",
"https://open.bigmodel.cn/api/anthropic/v1/models",
),
(
"https://api.kimi.com/coding",
"https://api.kimi.com/coding/v1/models",
),
(
"https://api.moonshot.cn/v1",
"https://api.moonshot.cn/v1/models",
),
(
"https://ark.cn-beijing.volces.com/api/v3",
"https://ark.cn-beijing.volces.com/api/v3/models",
),
(
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
"https://open.bigmodel.cn/api/coding/paas/v4/models",
),
];
for (base, expected) in cases {
assert_eq!(
model_discovery_url(base).unwrap().as_str(),
expected,
"base: {base}"
);
}
}
#[test]
fn model_discovery_urls_fall_back_without_a_version() {
let cases = [
(
"https://opencode.ai/zen/go/v1",
vec!["https://opencode.ai/zen/go/v1/models"],
),
(
"https://opencode.ai/zen/go",
vec![
"https://opencode.ai/zen/go/v1/models",
"https://opencode.ai/zen/go/models",
],
),
(
"https://api.example.com/openai/v1/models",
vec!["https://api.example.com/openai/v1/models"],
),
];
for (base, expected) in cases {
let actual = model_discovery_urls(base)
.unwrap()
.into_iter()
.map(|url| url.to_string())
.collect::<Vec<_>>();
assert_eq!(actual, expected, "base: {base}");
}
}
#[tokio::test]
async fn openai_model_discovery_uses_the_unversioned_fallback() {
let app = axum::Router::new().route(
"/proxy/models",
axum::routing::get(|| async {
axum::Json(serde_json::json!({ "data": [{ "id": "model-a" }] }))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let models = super::openai_models(
&reqwest::Client::new(),
&format!("http://{address}/proxy"),
"secret",
&serde_json::json!({}),
)
.await
.unwrap();
assert_eq!(models, vec!["model-a"]);
server.abort();
}
struct TestProvider { struct TestProvider {
invocation: Arc<Mutex<Option<ModelInvocation>>>, invocation: Arc<Mutex<Option<ModelInvocation>>>,
} }
struct CancellationProvider {
started: Arc<tokio::sync::Notify>,
}
impl Provider for TestProvider { impl Provider for TestProvider {
fn stream( fn stream(
&self, &self,
@@ -784,6 +1059,50 @@ mod tests {
} }
} }
impl Provider for CancellationProvider {
fn stream(
&self,
_invocation: ModelInvocation,
cancellation: CancellationToken,
) -> ProviderStream {
let started = self.started.clone();
Box::pin(async_stream::try_stream! {
started.notify_one();
cancellation.cancelled().await;
if false { yield ModelEvent::TextStart; }
})
}
}
async fn create_test_model(store: &Store) -> ModelConfig {
store
.create_model(&ModelConfigInput {
model_id: "reasoning-model".into(),
display_name: "Reasoning Model".into(),
model_type: ModelType::OpenAi,
base_url: "https://example.com/v1/responses".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "Reasoning Model".into(),
sort_order: 0,
reasoning_effort: Some("medium".into()),
openai_endpoint: "/v1/responses".into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: None,
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
})
.await
.unwrap()
}
#[tokio::test] #[tokio::test]
async fn connectivity_test_uses_the_configured_llm_provider() { async fn connectivity_test_uses_the_configured_llm_provider() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
@@ -794,36 +1113,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let invocation = Arc::new(Mutex::new(None)); let invocation = Arc::new(Mutex::new(None));
let provider = store let model = create_test_model(&store).await;
.create_provider(&ProviderEndpointInput {
name: "Test".into(),
provider_type: ProviderType::OpenAiResponses,
base_url: "https://example.com/v1".into(),
api_key: None,
custom_headers: serde_json::json!({}),
extra_params: serde_json::json!({}),
})
.await
.unwrap();
let model = store
.save_provider_model(
provider.provider_id,
&ProviderModelInput {
model_id: "reasoning-model".into(),
display_name: "Reasoning Model".into(),
endpoint_type: ProviderType::OpenAiResponses,
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: None,
max_output_tokens: None,
reasoning_enabled: true,
reasoning_effort: None,
supports_image_generation: false,
},
)
.await
.unwrap();
let service = ControlService::new( let service = ControlService::new(
store, store,
Arc::new(TestProvider { Arc::new(TestProvider {
@@ -832,7 +1122,10 @@ mod tests {
) )
.unwrap(); .unwrap();
let result = service.test_model(&model.model_hash).await.unwrap(); let result = service
.test_model(&model.model_hash, "test-id")
.await
.unwrap();
assert_eq!(result.output, "OK"); assert_eq!(result.output, "OK");
assert_eq!(result.output_tokens, 2); assert_eq!(result.output_tokens, 2);
@@ -854,6 +1147,42 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn connectivity_test_can_be_cancelled() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("cancel.db").display()
))
.await
.unwrap();
let model = create_test_model(&store).await;
let started = Arc::new(tokio::sync::Notify::new());
let service = ControlService::new(
store,
Arc::new(CancellationProvider {
started: started.clone(),
}),
)
.unwrap();
let running_service = service.clone();
let model_hash = model.model_hash.clone();
let task =
tokio::spawn(
async move { running_service.test_model(&model_hash, "cancel-test").await },
);
started.notified().await;
service.cancel_model_test("cancel-test");
assert!(matches!(task.await.unwrap(), Err(crate::Error::Cancelled)));
assert!(!service
.model_tests
.lock()
.unwrap()
.contains_key("cancel-test"));
}
#[test] #[test]
fn connectivity_output_token_estimate_handles_words_and_empty_text() { fn connectivity_output_token_estimate_handles_words_and_empty_text() {
assert_eq!(super::estimate_output_tokens("1 2 3"), 3); assert_eq!(super::estimate_output_tokens("1 2 3"), 3);
@@ -861,12 +1190,12 @@ mod tests {
} }
#[test] #[test]
fn model_discovery_url_uses_only_the_provider_origin() { fn model_discovery_url_keeps_provider_path_prefix() {
assert_eq!( assert_eq!(
super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions") super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions")
.unwrap() .unwrap()
.as_str(), .as_str(),
"https://example.com:8443/v1/models" "https://example.com:8443/arbitrary/v1/models"
); );
} }
@@ -895,7 +1224,7 @@ mod tests {
let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel(); let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel();
let app = axum::Router::new() let app = axum::Router::new()
.route("/v1/models", axum::routing::get(models)) .route("/custom/models", axum::routing::get(models))
.with_state(sender); .with_state(sender);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap(); let address = listener.local_addr().unwrap();
@@ -916,16 +1245,15 @@ mod tests {
) )
.unwrap(); .unwrap();
let result = service let result = service
.discover_input(&ProviderEndpointInput { .discover_models(&super::ModelDiscoveryInput {
name: "Test".into(), model_type: ModelType::OpenAi,
provider_type: ProviderType::OpenAiResponses,
base_url: format!("http://{address}/custom/responses"), base_url: format!("http://{address}/custom/responses"),
api_key: Some("secret".into()), api_key: "secret".into(),
custom_headers_enabled: true,
custom_headers: serde_json::json!({ custom_headers: serde_json::json!({
"uSeR-aGeNt": "inherited-user-agent", "uSeR-aGeNt": "inherited-user-agent",
"x-tenant": "tenant-a" "x-tenant": "tenant-a"
}), }),
extra_params: serde_json::json!({ "temperature": 0.7 }),
}) })
.await .await
.unwrap(); .unwrap();
@@ -933,7 +1261,8 @@ mod tests {
assert_eq!(result.models, vec!["model-a"]); assert_eq!(result.models, vec!["model-a"]);
let (method, uri, headers, body) = requests.recv().await.unwrap(); let (method, uri, headers, body) = requests.recv().await.unwrap();
assert_eq!(method, axum::http::Method::GET); assert_eq!(method, axum::http::Method::GET);
assert_eq!(uri.path(), "/v1/models"); // /custom/responses 剥掉端点段后是 /custom,发现地址为 /custom/models
assert_eq!(uri.path(), "/custom/models");
assert!(body.is_empty()); assert!(body.is_empty());
assert!(headers.get(axum::http::header::USER_AGENT).is_none()); assert!(headers.get(axum::http::header::USER_AGENT).is_none());
assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a"); assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");
+14 -1
View File
@@ -2,7 +2,8 @@ use crate::Result;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use crate::store::{ use crate::store::{
PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, TabSettings, DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage,
TabSettings,
}; };
use super::{ControlService, ObservabilitySettings}; use super::{ControlService, ObservabilitySettings};
@@ -60,3 +61,15 @@ pub async fn update_tab(
) -> Result<Json<TabSettings>> { ) -> Result<Json<TabSettings>> {
Ok(Json(service.set_tab_settings(settings).await?)) Ok(Json(service.set_tab_settings(settings).await?))
} }
pub async fn get_desktop(State(service): State<ControlService>) -> Result<Json<DesktopSettings>> {
Ok(Json(service.desktop_settings().await?))
}
pub async fn update_desktop(
State(service): State<ControlService>,
Json(settings): Json<DesktopSettings>,
) -> Result<Json<DesktopSettings>> {
service.set_desktop_settings(settings).await?;
get_desktop(State(service)).await
}
+90 -32
View File
@@ -48,7 +48,11 @@ impl CursorActor {
let tool_runtime = CursorToolRuntime::default(); let tool_runtime = CursorToolRuntime::default();
let context_sync = let context_sync =
RequestContextSynchronizer::new(handle.clone(), dependencies.store.clone()); RequestContextSynchronizer::new(handle.clone(), dependencies.store.clone());
let tools = ToolDispatcher::with_results(tool_runtime.clone(), results_tx.clone()); let tools = ToolDispatcher::with_results(
tool_runtime.clone(),
results_tx.clone(),
dependencies.store.clone(),
);
let mut run_resources = Some((results_rx, runtime_actions_rx, dependencies)); let mut run_resources = Some((results_rx, runtime_actions_rx, dependencies));
loop { loop {
let command = match receiver.recv().await { let command = match receiver.recv().await {
@@ -89,24 +93,42 @@ impl CursorActor {
.map(|parent| parent.tool_call_id.clone()), .map(|parent| parent.tool_call_id.clone()),
request.conversation_state.clone(), request.conversation_state.clone(),
); );
let parent = handle.parent().map(|parent| { let prepared = async {
( let parent = match handle.parent() {
crate::model::RunId::new(&parent.run_id), Some(parent) => {
parent.tool_call_id.clone(), let parent_run_id = dependencies
.store
.active_run_for_cursor_request(
&parent.request_id,
)
.await?
.ok_or_else(|| {
crate::Error::Protocol(format!(
"Cursor parent request {} has no active local Run",
parent.request_id
))
})?;
Some((
parent_run_id,
parent.tool_call_id.clone(),
))
}
None => None,
};
request::prepare(
handle.request_id(),
&request,
parent,
request::PrepareDependencies {
compiler: &dependencies.compiler,
store: &dependencies.store,
checkpoint: &checkpoint,
blob_sync: &blob_sync,
context_sync: &context_sync,
},
) )
}); .await
let prepared = request::prepare( }
handle.request_id(),
&request,
parent,
request::PrepareDependencies {
compiler: &dependencies.compiler,
store: &dependencies.store,
checkpoint: &checkpoint,
blob_sync: &blob_sync,
context_sync: &context_sync,
},
)
.await; .await;
let (prepared, context) = match prepared { let (prepared, context) = match prepared {
Ok(prepared) => prepared, Ok(prepared) => prepared,
@@ -133,15 +155,39 @@ impl CursorActor {
context.dynamic_tools.keys().cloned().collect(), context.dynamic_tools.keys().cloned().collect(),
context.turn_user.clone(), context.turn_user.clone(),
); );
if context.background_completion
&& dependencies
.run_registry
.insert_messages(
&prepared.conversation_id,
prepared.initial_messages.clone(),
)
.await
{
crate::cursor::lifecycle::finish_success(
&handle,
);
let _ = handle
.command(CursorCommand::Finished)
.await;
return;
}
let cancellation = handle.cancellation(); let cancellation = handle.cancellation();
let (port, core) = crate::client::session(256); let (port, core) = crate::client::session(256);
let core_commands = core.commands.clone();
let actor = RunActor::new( let actor = RunActor::new(
dependencies.store.clone(), dependencies.store.clone(),
dependencies.provider, dependencies.provider,
dependencies.run_registry, dependencies.run_registry,
); );
let core_run = let core_run = actor
actor.spawn(prepared, port, cancellation).await; .spawn(
prepared,
port,
core_commands,
cancellation,
)
.await;
let session = CursorSession::new( let session = CursorSession::new(
handle.clone(), handle.clone(),
dependencies.store, dependencies.store,
@@ -163,7 +209,6 @@ impl CursorActor {
%error, %error,
"Cursor session failed" "Cursor session failed"
); );
handle.cancel();
let _ = crate::cursor::lifecycle::fail( let _ = crate::cursor::lifecycle::fail(
&handle, &error, &handle, &error,
); );
@@ -217,12 +262,14 @@ impl CursorActor {
{ {
continue; continue;
} }
if tool_runtime.take_exec(close.id).await.is_some() match codec::stream_closed(close.id, &tool_runtime)
.await
{ {
results_tx.send_error(crate::Error::Protocol(format!( Ok(Some(completion)) => {
"Exec stream closed before result for id: {}", results_tx.send(completion)
close.id }
))); Ok(None) => {}
Err(error) => results_tx.send_error(error),
} }
} }
Some(Message::Throw(throw)) => { Some(Message::Throw(throw)) => {
@@ -293,13 +340,12 @@ impl CursorActor {
// //
// The remaining unimplemented Action variants are // The remaining unimplemented Action variants are
// ShellCommandAction, StartPlanAction, // ShellCommandAction, StartPlanAction,
// AsyncAskQuestionCompletionAction, CancelSubagentAction, // AsyncAskQuestionCompletionAction, BackgroundShellAction,
// BackgroundShellAction, BackgroundSubagentAction, // BackgroundSubagentAction,
// SubscriptionNotificationAction and GoalContinuationAction. // SubscriptionNotificationAction and GoalContinuationAction.
// CancelSubagentAction must not start an LLM; variants whose wire // Variants whose wire behavior is not captured yet need evidence
// behavior is not captured yet need evidence before assigning // before assigning semantics. Every unsupported runtime Action must
// semantics. Every unsupported runtime Action must return an explicit // return an explicit Protocol Error rather than falling through silently.
// Protocol Error rather than falling through silently.
Some( Some(
pb::agent_client_message::Message::ConversationAction( pb::agent_client_message::Message::ConversationAction(
action, action,
@@ -323,6 +369,18 @@ impl CursorActor {
)); ));
} }
} }
Some(
pb::conversation_action::Action::CancelSubagentAction(
action,
),
) => {
if let Some(id) = tool_runtime
.running_task_exec_id(&action.subagent_id)
.await
{
let _ = handle.emit(&codec::abort(id));
}
}
Some(action) => { Some(action) => {
results_tx.send_error(crate::Error::Protocol(format!( results_tx.send_error(crate::Error::Protocol(format!(
"unsupported runtime ConversationAction: {}", "unsupported runtime ConversationAction: {}",
+6 -6
View File
@@ -128,7 +128,7 @@ async fn bidi_append_handler(
let conversation_id = decoded.conversation_id().map(str::to_owned); let conversation_id = decoded.conversation_id().map(str::to_owned);
let trace_metadata = decoded.trace_metadata(); let trace_metadata = decoded.trace_metadata();
let local = if let Some(model_id) = decoded.model_id() { let local = if let Some(model_id) = decoded.model_id() {
if registry.store().provider_model(model_id).await?.is_some() { if registry.store().model(model_id).await?.is_some() {
tracing::info!( tracing::info!(
request_id = decoded.request_id, request_id = decoded.request_id,
model_id, model_id,
@@ -203,12 +203,12 @@ async fn buffered(request: Request<Body>) -> Result<(axum::http::request::Parts,
} }
fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> { fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> {
let run_id = header_text(headers, "x-parent-request-id")?; let request_id = header_text(headers, "x-parent-request-id")?;
let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?; let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?;
match (run_id, tool_call_id) { match (request_id, tool_call_id) {
(None, None) => Ok(None), (None, None) => Ok(None),
(Some(run_id), Some(tool_call_id)) => Ok(Some(CursorParent { (Some(request_id), Some(tool_call_id)) => Ok(Some(CursorParent {
run_id: run_id.into(), request_id: request_id.into(),
tool_call_id: tool_call_id.into(), tool_call_id: tool_call_id.into(),
})), })),
_ => Err(crate::Error::Protocol( _ => Err(crate::Error::Protocol(
@@ -270,7 +270,7 @@ mod tests {
assert_eq!( assert_eq!(
parent_headers(&headers).unwrap(), parent_headers(&headers).unwrap(),
Some(CursorParent { Some(CursorParent {
run_id: "parent-run".into(), request_id: "parent-run".into(),
tool_call_id: "parent-call".into(), tool_call_id: "parent-call".into(),
}) })
); );
+46 -56
View File
@@ -1,5 +1,3 @@
use std::collections::HashMap;
use axum::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
extract::{Extension, State}, extract::{Extension, State},
@@ -14,7 +12,7 @@ use crate::{
proxy::{self, CursorProxy}, proxy::{self, CursorProxy},
CursorSessionRegistry, CursorSessionRegistry,
}, },
model::{format_token_count, parse_token_count, ProviderModel}, model::{format_token_count, parse_token_count, ModelConfig, ModelType},
Error, Result, Error, Result,
}; };
@@ -205,7 +203,7 @@ const EFFORTS: [(&str, &str); 5] = [
]; ];
const DEFAULT_CONTEXT: &str = "200k"; const DEFAULT_CONTEXT: &str = "200k";
fn context_options(model: &ProviderModel) -> Vec<(String, String)> { fn context_options(model: &ModelConfig) -> Vec<(String, String)> {
let mut contexts = CONTEXTS let mut contexts = CONTEXTS
.into_iter() .into_iter()
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned())) .map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
@@ -227,30 +225,12 @@ pub async fn available_models(
Extension(proxy): Extension<CursorProxy>, Extension(proxy): Extension<CursorProxy>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response<Body>> { ) -> Result<Response<Body>> {
let models = registry.store().provider_models(true).await?; let models = registry.store().models().await?;
let provider_names = registry
.store()
.providers()
.await?
.into_iter()
.map(|provider| (provider.provider_id, provider.name))
.collect::<HashMap<_, _>>();
tracing::info!( tracing::info!(
model_count = models.len(), model_count = models.len(),
"appending BYOK models to Cursor AvailableModels" "appending BYOK models to Cursor AvailableModels"
); );
let available_models = models let available_models = models.iter().map(available_model).collect::<Vec<_>>();
.iter()
.map(|model| {
let provider_name = provider_names.get(&model.provider_id).ok_or_else(|| {
Error::Config(format!(
"provider {} for model {} does not exist",
model.provider_id, model.model_hash
))
})?;
Ok(available_model(model, provider_name))
})
.collect::<Result<Vec<_>>>()?;
let local = AvailableModelsAddition { let local = AvailableModelsAddition {
model_names: models model_names: models
.iter() .iter()
@@ -273,7 +253,7 @@ pub async fn usable_models(
Extension(proxy): Extension<CursorProxy>, Extension(proxy): Extension<CursorProxy>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response<Body>> { ) -> Result<Response<Body>> {
let models = registry.store().provider_models(true).await?; let models = registry.store().models().await?;
tracing::info!( tracing::info!(
model_count = models.len(), model_count = models.len(),
"appending BYOK models to Cursor GetUsableModels" "appending BYOK models to Cursor GetUsableModels"
@@ -340,14 +320,14 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
Ok((true, &body[5..])) Ok((true, &body[5..]))
} }
fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel { fn available_model(model: &ModelConfig) -> AvailableModel {
let contexts = context_options(model); let contexts = context_options(model);
let variants = model_variants(model, &contexts); let variants = model_variants(model, &contexts);
let legacy_slugs = variants let legacy_slugs = variants
.iter() .iter()
.filter_map(|variant| variant.legacy_slug.clone()) .filter_map(|variant| variant.legacy_slug.clone())
.collect(); .collect();
let tooltip = model_tooltip(model, "200K", "high", false); let tooltip = model_tooltip(model);
AvailableModel { AvailableModel {
name: model.model_hash.clone(), name: model.model_hash.clone(),
default_on: true, default_on: true,
@@ -376,7 +356,10 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
display_name: "Cursor".into(), display_name: "Cursor".into(),
}), }),
model_picker_badges: vec![ModelPickerBadge { model_picker_badges: vec![ModelPickerBadge {
label: provider_name.into(), label: match model.model_type {
ModelType::OpenAi => "OpenAI".into(),
ModelType::Anthropic => "Anthropic".into(),
},
variant: 1, variant: 1,
dismiss_on_selection: false, dismiss_on_selection: false,
}], }],
@@ -447,7 +430,7 @@ fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefiniti
] ]
} }
fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<ModelVariant> { fn model_variants(model: &ModelConfig, contexts: &[(String, String)]) -> Vec<ModelVariant> {
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2); let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
for (context, context_name) in contexts { for (context, context_name) in contexts {
for (effort, effort_name) in EFFORTS { for (effort, effort_name) in EFFORTS {
@@ -467,7 +450,7 @@ fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<M
} }
fn model_variant( fn model_variant(
model: &ProviderModel, model: &ModelConfig,
context: &str, context: &str,
context_name: &str, context_name: &str,
effort: &str, effort: &str,
@@ -507,7 +490,7 @@ fn model_variant(
is_max_mode: false, is_max_mode: false,
is_default_max_config: is_default.then_some(true), is_default_max_config: is_default.then_some(true),
is_default_non_max_config: is_default.then_some(true), is_default_non_max_config: is_default.then_some(true),
tooltip_data: Some(model_tooltip(model, context_name, effort, fast)), tooltip_data: Some(model_tooltip(model)),
display_name_outside_picker: Some(display_name), display_name_outside_picker: Some(display_name),
variant_string_representation: Some(format!( variant_string_representation: Some(format!(
"{}[context={context},effort={effort},fast={fast}]", "{}[context={context},effort={effort},fast={fast}]",
@@ -521,22 +504,13 @@ fn model_variant(
} }
} }
fn model_tooltip( fn model_tooltip(model: &ModelConfig) -> TooltipData {
model: &ProviderModel,
context_name: &str,
effort: &str,
fast: bool,
) -> TooltipData {
let fast_label = if fast { " (Fast)" } else { "" };
TooltipData { TooltipData {
markdown_content: Some(format!( markdown_content: Some(model.tooltip_data.clone()),
"**{}{fast_label}**<br /><br />{context_name} context window<br /><br />*Version: {effort} effort*",
model.display_name
)),
} }
} }
fn usable_model(model: &ProviderModel) -> agent::ModelDetails { fn usable_model(model: &ModelConfig) -> agent::ModelDetails {
agent::ModelDetails { agent::ModelDetails {
model_id: model.model_hash.clone(), model_id: model.model_hash.clone(),
display_model_id: model.model_hash.clone(), display_model_id: model.model_hash.clone(),
@@ -555,25 +529,34 @@ mod tests {
#[test] #[test]
fn maps_byok_model_to_cursor_catalog_fields() { fn maps_byok_model_to_cursor_catalog_fields() {
let model = ProviderModel { let model = ModelConfig {
model_hash: "33ceed20".into(), model_hash: "33ceed20".into(),
provider_id: 1,
model_id: "deepseek-v4-flash".into(),
display_name: "DeepSeek V4 Flash".into(),
endpoint_type: crate::model::ProviderType::OpenAiResponses,
request_url: String::new(),
enabled: true,
sort_order: 0, sort_order: 0,
context_window_tokens: Some(272_000), display_name: "DeepSeek V4 Flash".into(),
max_output_tokens: None, model_type: ModelType::OpenAi,
reasoning_enabled: false, base_url: "https://example.com/v1/responses".into(),
use_full_url: true,
api_key: "secret".into(),
tooltip_data: "DeepSeek V4 Flash".into(),
model_id: "deepseek-v4-flash".into(),
reasoning_effort: None, reasoning_effort: None,
supports_image_generation: false, openai_endpoint: "/v1/responses".into(),
openai_extra_params_enabled: false,
openai_extra_params: serde_json::json!({}),
custom_headers_enabled: false,
custom_headers: serde_json::json!({}),
anthropic_extra_params_enabled: false,
anthropic_extra_params: serde_json::json!({}),
context_window_tokens: Some(272_000),
max_completion_tokens: None,
anthropic_max_tokens: None,
anthropic_thinking_effort: None,
thinking_budget_tokens: None,
created_at_ms: 0, created_at_ms: 0,
updated_at_ms: 0, updated_at_ms: 0,
}; };
let mapped = available_model(&model, "OpenRouter"); let mapped = available_model(&model);
assert_eq!(mapped.name, "33ceed20"); assert_eq!(mapped.name, "33ceed20");
assert!(mapped.default_on); assert!(mapped.default_on);
assert_eq!(mapped.supports_agent, Some(true)); assert_eq!(mapped.supports_agent, Some(true));
@@ -591,6 +574,13 @@ mod tests {
); );
assert_eq!(mapped.server_model_name.as_deref(), Some("33ceed20")); assert_eq!(mapped.server_model_name.as_deref(), Some("33ceed20"));
assert_eq!(mapped.named_model_section_index, Some(1)); assert_eq!(mapped.named_model_section_index, Some(1));
assert_eq!(
mapped
.tooltip_data
.as_ref()
.and_then(|tooltip| tooltip.markdown_content.as_deref()),
Some("DeepSeek V4 Flash")
);
assert_eq!(mapped.vendor_name.as_deref(), Some("cursor")); assert_eq!(mapped.vendor_name.as_deref(), Some("cursor"));
assert_eq!(mapped.parameter_definitions.len(), 3); assert_eq!(mapped.parameter_definitions.len(), 3);
let context = mapped let context = mapped
@@ -643,7 +633,7 @@ mod tests {
assert_eq!(mapped.variants.len(), 50); assert_eq!(mapped.variants.len(), 50);
assert_eq!(mapped.legacy_slugs.len(), 50); assert_eq!(mapped.legacy_slugs.len(), 50);
assert_eq!(mapped.model_picker_badges.len(), 1); assert_eq!(mapped.model_picker_badges.len(), 1);
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter"); assert_eq!(mapped.model_picker_badges[0].label, "OpenAI");
assert!(!mapped.model_picker_badges[0].dismiss_on_selection); assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
let default = mapped let default = mapped
.variants .variants
+94 -6
View File
@@ -1,11 +1,35 @@
use crate::{store::BlobId, store::Store}; use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use crate::store::{BlobId, BufferedCursorTraceChunk, Store};
#[derive(Clone)] #[derive(Clone)]
pub struct CursorTraceRecorder { pub struct CursorTraceRecorder {
store: Store, store: Store,
request_id: String, request_id: String,
chunks: Arc<Mutex<TraceChunkBuffer>>,
finished: Arc<AtomicBool>,
} }
#[derive(Default)]
struct TraceChunkBuffer {
chunks: Vec<BufferedCursorTraceChunk>,
bytes: usize,
first_chunk_at: Option<Instant>,
generation: u64,
}
const MAX_BUFFERED_CHUNKS: usize = 32;
const MAX_BUFFERED_BYTES: usize = 256 * 1024;
const MAX_BUFFER_AGE: Duration = Duration::from_millis(50);
impl CursorTraceRecorder { impl CursorTraceRecorder {
pub async fn begin( pub async fn begin(
store: Store, store: Store,
@@ -21,6 +45,8 @@ impl CursorTraceRecorder {
Ok(true) => Some(Self { Ok(true) => Some(Self {
store, store,
request_id: request_id.into(), request_id: request_id.into(),
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
finished: Arc::new(AtomicBool::new(false)),
}), }),
Ok(false) => None, Ok(false) => None,
Err(error) => { Err(error) => {
@@ -35,6 +61,8 @@ impl CursorTraceRecorder {
Ok(true) => Some(Self { Ok(true) => Some(Self {
store, store,
request_id: request_id.into(), request_id: request_id.into(),
chunks: Arc::new(Mutex::new(TraceChunkBuffer::default())),
finished: Arc::new(AtomicBool::new(false)),
}), }),
Ok(false) => None, Ok(false) => None,
Err(error) => { Err(error) => {
@@ -115,16 +143,56 @@ impl CursorTraceRecorder {
} }
pub async fn response_chunk(&self, source: &str, data: &[u8]) { pub async fn response_chunk(&self, source: &str, data: &[u8]) {
if let Err(error) = self let mut buffer = self.chunks.lock().await;
.store if self.finished.load(Ordering::Acquire) {
.add_cursor_trace_response_chunk(&self.request_id, source, data) return;
.await }
let schedule_flush = if buffer.chunks.is_empty() {
buffer.generation = buffer.generation.wrapping_add(1);
buffer.first_chunk_at = Some(Instant::now());
Some(buffer.generation)
} else {
None
};
buffer.bytes += data.len();
buffer
.chunks
.push(BufferedCursorTraceChunk::new(source, data));
let expired = buffer
.first_chunk_at
.is_some_and(|started| started.elapsed() >= MAX_BUFFER_AGE);
if buffer.chunks.len() >= MAX_BUFFERED_CHUNKS
|| buffer.bytes >= MAX_BUFFERED_BYTES
|| expired
{ {
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk"); if let Err(error) = self.flush_locked(&mut buffer).await {
tracing::warn!(request_id = self.request_id, %error, "failed to record Cursor response chunk");
}
}
drop(buffer);
if let Some(generation) = schedule_flush {
let recorder = self.clone();
tokio::spawn(async move {
tokio::time::sleep(MAX_BUFFER_AGE).await;
let mut buffer = recorder.chunks.lock().await;
if buffer.generation == generation {
if let Err(error) = recorder.flush_locked(&mut buffer).await {
tracing::warn!(request_id = recorder.request_id, %error, "failed to flush Cursor response chunks");
}
}
});
} }
} }
pub async fn finish(&self, error: Option<&str>) { pub async fn finish(&self, error: Option<&str>) {
if self.finished.swap(true, Ordering::AcqRel) {
return;
}
let mut buffer = self.chunks.lock().await;
if let Err(store_error) = self.flush_locked(&mut buffer).await {
tracing::warn!(request_id = self.request_id, %store_error, "failed to flush Cursor response chunks");
}
drop(buffer);
if let Err(store_error) = self if let Err(store_error) = self
.store .store
.finish_cursor_trace(&self.request_id, error) .finish_cursor_trace(&self.request_id, error)
@@ -133,4 +201,24 @@ impl CursorTraceRecorder {
tracing::warn!(request_id = self.request_id, %store_error, "failed to finish Cursor trace"); tracing::warn!(request_id = self.request_id, %store_error, "failed to finish Cursor trace");
} }
} }
async fn flush_locked(&self, buffer: &mut TraceChunkBuffer) -> crate::Result<()> {
if buffer.chunks.is_empty() {
return Ok(());
}
let chunks = std::mem::take(&mut buffer.chunks);
buffer.bytes = 0;
buffer.first_chunk_at = None;
if let Err(error) = self
.store
.add_cursor_trace_response_chunks(&self.request_id, &chunks)
.await
{
buffer.bytes = chunks.iter().map(|chunk| chunk.data.len()).sum();
buffer.first_chunk_at = Some(Instant::now());
buffer.chunks = chunks;
return Err(error);
}
Ok(())
}
} }
+76 -10
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeSet; use std::collections::BTreeMap;
use crate::{cursor::proto::agent::v1 as pb, Error, Result}; use crate::{cursor::proto::agent::v1 as pb, Error, Result};
@@ -35,8 +35,7 @@ pub(super) fn project(
)); ));
} }
let mut identities = BTreeSet::new(); let mut completions = BTreeMap::new();
let mut contexts = Vec::with_capacity(action.completions.len());
let mut has_shell = false; let mut has_shell = false;
let mut has_subagent = false; let mut has_subagent = false;
for completion in &action.completions { for completion in &action.completions {
@@ -88,17 +87,30 @@ pub(super) fn project(
} }
pb::BackgroundTaskKind::Unspecified => unreachable!(), pb::BackgroundTaskKind::Unspecified => unreachable!(),
}; };
let identity = agent_id.unwrap_or(&completion.task_id); let tool_call_id = completion
let identity = format!("{}:{identity}", kind.as_str_name()); .tool_call_id
if !identities.insert(identity.clone()) { .as_deref()
.filter(|id| !id.is_empty())
.ok_or_else(|| {
Error::Protocol("background task completion has no tool_call_id".into())
})?;
let task_identity = agent_id.unwrap_or(&completion.task_id);
let identity = format!("{}:{task_identity}:{tool_call_id}", kind.as_str_name());
let context = completion_context(completion, kind, agent_id)?;
if completions
.insert(identity.clone(), (completion, context))
.is_some()
{
return Err(Error::Protocol(format!( return Err(Error::Protocol(format!(
"duplicate background task completion: {identity}" "duplicate background task completion: {identity}"
))); )));
} }
contexts.push(completion_context(completion, kind, agent_id)?);
} }
let first = &action.completions[0]; let (first, _) = completions
.values()
.next()
.expect("background completion action was validated as non-empty");
let text = match (has_shell, has_subagent) { let text = match (has_shell, has_subagent) {
(true, false) => SHELL_FOLLOW_UP.into(), (true, false) => SHELL_FOLLOW_UP.into(),
(false, true) => FOLLOW_UP.into(), (false, true) => FOLLOW_UP.into(),
@@ -106,12 +118,16 @@ pub(super) fn project(
(false, false) => unreachable!(), (false, false) => unreachable!(),
}; };
Ok(Projection { Ok(Projection {
context: contexts.join("\n\n"), context: completions
.values()
.map(|(_, context)| context.as_str())
.collect::<Vec<_>>()
.join("\n\n"),
turn_user: pb::UserMessage { turn_user: pb::UserMessage {
text, text,
message_id: format!( message_id: format!(
"background-completed:{}", "background-completed:{}",
identities.into_iter().collect::<Vec<_>>().join(":") completions.keys().cloned().collect::<Vec<_>>().join(":")
), ),
mode, mode,
is_simulated_msg: Some(true), is_simulated_msg: Some(true),
@@ -272,6 +288,56 @@ mod tests {
assert!(projection.turn_user.text.contains(FOLLOW_UP)); assert!(projection.turn_user.text.contains(FOLLOW_UP));
} }
#[test]
fn completion_batch_projection_is_independent_of_input_order() {
let first = completion();
let mut second = completion();
second.task_id = "child-id-2".into();
second.subagent_id = Some("child-id-2".into());
second.tool_call_id = Some("task-call-2".into());
let forward = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![first.clone(), second.clone()],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
let reversed = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![second, first],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
assert_eq!(forward.turn_user, reversed.turn_user);
assert_eq!(forward.context, reversed.context);
}
#[test]
fn resumed_subagent_completions_use_the_task_call_as_part_of_their_identity() {
let first = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![completion()],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
let mut resumed = completion();
resumed.tool_call_id = Some("task-call-2".into());
let second = project(
&pb::BackgroundTaskCompletionAction {
completions: vec![resumed],
},
pb::AgentMode::Multitask as i32,
)
.unwrap();
assert_ne!(first.turn_user.message_id, second.turn_user.message_id);
assert!(first.turn_user.message_id.ends_with(":task-call"));
assert!(second.turn_user.message_id.ends_with(":task-call-2"));
}
#[test] #[test]
fn completion_requires_the_captured_subagent_identity_and_terminal_reason() { fn completion_requires_the_captured_subagent_identity_and_terminal_reason() {
let mut value = completion(); let mut value = completion();
+4 -6
View File
@@ -478,9 +478,9 @@ pub fn dynamic_mcp(
Ok(output) Ok(output)
} }
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> { fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value> {
let schema = parameters let schema = parameters
.as_object_mut() .as_object()
.ok_or_else(|| invalid_mcp_parameters(tool_name))?; .ok_or_else(|| invalid_mcp_parameters(tool_name))?;
match schema.get("type") { match schema.get("type") {
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters), Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
@@ -505,7 +505,6 @@ fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Va
if !object_only_union { if !object_only_union {
return Err(invalid_mcp_parameters(tool_name)); return Err(invalid_mcp_parameters(tool_name));
} }
schema.insert("type".into(), Value::String("object".into()));
Ok(parameters) Ok(parameters)
} }
@@ -614,7 +613,7 @@ mod tests {
} }
#[test] #[test]
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() { fn dynamic_mcp_preserves_cursor_object_union_schema() {
let original_schema = serde_json::json!({ let original_schema = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [ "anyOf": [
@@ -655,8 +654,7 @@ mod tests {
.get("cursor-app-control-move_agent_to_cloned_root") .get("cursor-app-control-move_agent_to_cloned_root")
.unwrap(); .unwrap();
assert_eq!(definition.parameters["type"], "object"); assert_eq!(definition.parameters, original_schema);
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
assert_eq!( assert_eq!(
wire.input_schema_json.as_deref(), wire.input_schema_json.as_deref(),
Some(original_json.as_str()) Some(original_json.as_str())
+176 -35
View File
@@ -1,5 +1,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use uuid::Uuid;
use crate::{ use crate::{
cursor::prompting::{Mode, PromptCompiler}, cursor::prompting::{Mode, PromptCompiler},
cursor::{ cursor::{
@@ -39,6 +41,7 @@ pub struct CursorRunContext {
pub dynamic_tools: BTreeMap<String, pb::McpToolDefinition>, pub dynamic_tools: BTreeMap<String, pb::McpToolDefinition>,
pub checkpoint_prompt: PromptSpec, pub checkpoint_prompt: PromptSpec,
pub compacting: bool, pub compacting: bool,
pub background_completion: bool,
} }
pub(crate) struct PrepareDependencies<'a> { pub(crate) struct PrepareDependencies<'a> {
@@ -71,9 +74,7 @@ pub(crate) async fn prepare(
.clone() .clone()
.unwrap_or_else(|| request_id.into()), .unwrap_or_else(|| request_id.into()),
); );
// RunSSE/Bidi request_id identifies this concrete execution attempt. Cursor may let run_id = execution_run_id(request_id);
// reuse AgentRunRequest.run_id when a queued or subagent-driven attempt resumes.
let run_id = RunId::new(request_id);
let mut base_messages = if request.conversation_state.is_some() { let mut base_messages = if request.conversation_state.is_some() {
Some( Some(
checkpoint checkpoint
@@ -123,24 +124,20 @@ pub(crate) async fn prepare(
mode: mode_number, mode: mode_number,
mut turn_user, mut turn_user,
action_context, action_context,
event_id, mut event_id,
input_id, input_id,
starts_turn, starts_turn,
compacting, compacting,
background_completion, background_completion,
} = action(request_id, request)?; } = action(request)?;
let checkpoint_mode = if request.subagent_type_name.is_some() { let checkpoint_mode = if request.subagent_type_name.is_some() {
Mode::Subagent Mode::Subagent
} else { } else {
mode_from_proto(mode_number)? mode_from_proto(mode_number)?
}; };
let mut model = model::requested_model(request)?; let mut model = model::requested_model(request)?;
if let Some(provider_model) = store if let Some(configured_model) = store.model(&model.model_id).await? {
.provider_model(&model.model_id) configured_model.configure(&mut model);
.await?
.filter(|model| model.enabled)
{
provider_model.configure(&mut model);
} }
let dynamic = context::dynamic_mcp(request, &request_context)?; let dynamic = context::dynamic_mcp(request, &request_context)?;
let subagent_model_overrides = model::overrides(request)?; let subagent_model_overrides = model::overrides(request)?;
@@ -176,16 +173,45 @@ pub(crate) async fn prepare(
} }
Some(_) | None => store.ensure_conversation(&conversation_id).await?, Some(_) | None => store.ensure_conversation(&conversation_id).await?,
}; };
let base_revision_id = match input_id { let base_revision_id = match input_id.as_deref() {
Some(input_id) => { Some(input_id) => {
store store
.anchor_input(&conversation_id, &input_id, proposed_base_revision_id) .anchor_input(&conversation_id, input_id, proposed_base_revision_id)
.await? .await?
} }
None => proposed_base_revision_id, None => proposed_base_revision_id,
}; };
let mut projected_user_context = if input_id.is_some() && !compacting && !background_completion
{
runtime::compile_request_context(
"identity",
&request_context,
base_messages.as_deref().unwrap_or_default(),
)?
} else {
None
};
if event_id.is_none() {
if let (Some(input_id), Some(user)) = (input_id.as_deref(), turn_user.as_ref()) {
event_id = Some(
runtime::user_event_id(
input_id,
checkpoint_mode,
user,
&request_context,
&action_context,
projected_user_context
.as_ref()
.map(|message| &message.content),
compiler,
blob_sync,
)
.await?,
);
}
}
let existing_runtime = match event_id.as_deref() { let existing_runtime = match event_id.as_deref() {
Some(event_id) if !background_completion => { Some(event_id) => {
store store
.message(&conversation_id, &format!("runtime:{event_id}")) .message(&conversation_id, &format!("runtime:{event_id}"))
.await? .await?
@@ -197,6 +223,10 @@ pub(crate) async fn prepare(
let message_id = format!("request-context:{event_id}"); let message_id = format!("request-context:{event_id}");
match store.message(&conversation_id, &message_id).await? { match store.message(&conversation_id, &message_id).await? {
Some(message) => Some(message), Some(message) => Some(message),
None if input_id.is_some() => projected_user_context.take().map(|mut message| {
message.message_id = message_id;
message
}),
None => runtime::compile_request_context( None => runtime::compile_request_context(
event_id, event_id,
&request_context, &request_context,
@@ -206,19 +236,27 @@ pub(crate) async fn prepare(
} }
_ => None, _ => None,
}; };
let initial_messages = if compacting { let mut initial_messages = if compacting {
Vec::new() Vec::new()
} else { } else {
match (turn_user.clone(), event_id) { match (turn_user.clone(), event_id) {
(Some(mut user), Some(event_id)) if background_completion => { (Some(mut user), Some(event_id)) if background_completion => {
let (message, text) = runtime::compile_background( let (message, text) = match existing_runtime {
event_id, Some(message) => {
&user, let text = runtime_message_text(&message)?;
&request_context, (message, text)
&action_context, }
blob_sync, None => {
) runtime::compile_background(
.await?; event_id,
&user,
&request_context,
&action_context,
blob_sync,
)
.await?
}
};
user.text = text; user.text = text;
turn_user = Some(user); turn_user = Some(user);
vec![message] vec![message]
@@ -252,6 +290,10 @@ pub(crate) async fn prepare(
} }
} }
}; };
let (base_revision_id, reused) = store
.match_revision_prefix(&conversation_id, base_revision_id, &initial_messages)
.await?;
initial_messages.drain(..reused);
let action = if compacting { let action = if compacting {
RunAction::Compact RunAction::Compact
} else if starts_turn { } else if starts_turn {
@@ -286,6 +328,7 @@ pub(crate) async fn prepare(
Ok(( Ok((
PreparedRun { PreparedRun {
run_id, run_id,
cursor_request_id: Some(request_id.into()),
conversation_id, conversation_id,
kind, kind,
model, model,
@@ -305,10 +348,25 @@ pub(crate) async fn prepare(
.collect(), .collect(),
checkpoint_prompt, checkpoint_prompt,
compacting, compacting,
background_completion,
}, },
)) ))
} }
fn runtime_message_text(message: &CanonicalMessage) -> Result<String> {
let MessageContent::Parts { parts } = &message.content else {
return Err(Error::Protocol(
"stored runtime message does not contain parts".into(),
));
};
let Some(ContentPart::Text { text }) = parts.first() else {
return Err(Error::Protocol(
"stored runtime message does not start with text".into(),
));
};
Ok(text.clone())
}
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> { fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
match (subagent_type_name, parent) { match (subagent_type_name, parent) {
(None | Some("side-chat"), _) => Ok(RunKind::Root), (None | Some("side-chat"), _) => Ok(RunKind::Root),
@@ -348,12 +406,17 @@ fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
Ok(()) Ok(())
} }
fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProjection> { fn execution_run_id(request_id: &str) -> RunId {
let mode = request let execution_id = Uuid::new_v4().simple().to_string();
RunId::new(format!("{request_id}:{}", &execution_id[..8]))
}
fn action(request: &pb::AgentRunRequest) -> Result<ActionProjection> {
let conversation_mode = request
.conversation_state .conversation_state
.as_ref() .as_ref()
.and_then(|state| state.mode) .and_then(|state| state.mode);
.unwrap_or(pb::AgentMode::Agent as i32); let mode = conversation_mode.unwrap_or(pb::AgentMode::Agent as i32);
let Some(action) = request let Some(action) = request
.action .action
.as_ref() .as_ref()
@@ -375,6 +438,11 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
let user = action.user_message.as_ref().ok_or_else(|| { let user = action.user_message.as_ref().ok_or_else(|| {
Error::Protocol("Cursor user message action has no UserMessage".into()) Error::Protocol("Cursor user message action has no UserMessage".into())
})?; })?;
let mode = if user.mode == pb::AgentMode::Unspecified as i32 {
conversation_mode.unwrap_or(user.mode)
} else {
user.mode
};
if user.message_id.is_empty() { if user.message_id.is_empty() {
return Err(Error::Protocol( return Err(Error::Protocol(
"Cursor user message action has no message_id".into(), "Cursor user message action has no message_id".into(),
@@ -382,7 +450,7 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
} }
if user.text.trim() == "/summarize" { if user.text.trim() == "/summarize" {
return Ok(ActionProjection { return Ok(ActionProjection {
mode: user.mode, mode,
turn_user: Some(user.clone()), turn_user: Some(user.clone()),
action_context: String::new(), action_context: String::new(),
event_id: None, event_id: None,
@@ -405,12 +473,13 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.cloned(), .cloned(),
); );
let input_id = format!("cursor:user:{}", user.message_id);
Ok(ActionProjection { Ok(ActionProjection {
mode: user.mode, mode,
turn_user: Some(user.clone()), turn_user: Some(user.clone()),
action_context: context.join("\n\n"), action_context: context.join("\n\n"),
event_id: Some(format!("run-request:{request_id}")), event_id: None,
input_id: Some(format!("cursor:user:{}", user.message_id)), input_id: Some(input_id),
starts_turn: true, starts_turn: true,
compacting: false, compacting: false,
background_completion: false, background_completion: false,
@@ -418,10 +487,11 @@ fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProje
} }
pb::conversation_action::Action::BackgroundTaskCompletionAction(action) => { pb::conversation_action::Action::BackgroundTaskCompletionAction(action) => {
let projection = background::project(action, mode)?; let projection = background::project(action, mode)?;
let event_id = projection.turn_user.message_id.clone();
Ok(ActionProjection { Ok(ActionProjection {
mode, mode,
action_context: projection.context, action_context: projection.context,
event_id: Some(format!("run-request:{request_id}")), event_id: Some(event_id),
input_id: None, input_id: None,
turn_user: Some(projection.turn_user), turn_user: Some(projection.turn_user),
starts_turn: true, starts_turn: true,
@@ -598,6 +668,18 @@ mod tests {
)); ));
} }
#[test]
fn execution_run_id_keeps_the_request_id_and_adds_eight_uuid_hex_digits() {
let run_id = execution_run_id("01bba7c5-9c00-4922-b1df-1f58146b5d90");
let suffix = run_id
.as_str()
.strip_prefix("01bba7c5-9c00-4922-b1df-1f58146b5d90:")
.unwrap();
assert_eq!(suffix.len(), 8);
assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
#[test] #[test]
fn current_user_message_consumes_the_mode_instead_of_history_mode() { fn current_user_message_consumes_the_mode_instead_of_history_mode() {
let request = pb::AgentRunRequest { let request = pb::AgentRunRequest {
@@ -621,7 +703,7 @@ mod tests {
}), }),
..Default::default() ..Default::default()
}; };
let projection = action("request", &request).unwrap(); let projection = action(&request).unwrap();
assert_eq!(projection.mode, pb::AgentMode::Ask as i32); assert_eq!(projection.mode, pb::AgentMode::Ask as i32);
assert_eq!( assert_eq!(
projection.input_id.as_deref(), projection.input_id.as_deref(),
@@ -630,6 +712,65 @@ mod tests {
assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Ask); assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Ask);
} }
#[test]
fn queued_user_message_without_mode_inherits_conversation_mode() {
let request = pb::AgentRunRequest {
conversation_state: Some(pb::ConversationStateStructure {
mode: Some(pb::AgentMode::Agent as i32),
..Default::default()
}),
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(pb::UserMessage {
text: "queued follow-up".into(),
message_id: "queued-user-message".into(),
..Default::default()
}),
..Default::default()
},
)),
..Default::default()
}),
..Default::default()
};
let projection = action(&request).unwrap();
assert_eq!(projection.mode, pb::AgentMode::Agent as i32);
assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Agent);
}
#[test]
fn queued_messages_keep_distinct_input_anchors_until_runtime_identity_is_compiled() {
let request = |message_id: &str| pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(pb::UserMessage {
text: "queued follow-up".into(),
message_id: message_id.into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
}),
..Default::default()
},
)),
..Default::default()
}),
..Default::default()
};
let first = action(&request("message-one")).unwrap();
let second = action(&request("message-two")).unwrap();
assert_eq!(first.event_id, None);
assert_eq!(second.event_id, None);
assert_eq!(first.input_id.as_deref(), Some("cursor:user:message-one"));
assert_eq!(second.input_id.as_deref(), Some("cursor:user:message-two"));
assert_ne!(first.input_id, second.input_id);
}
#[test] #[test]
fn execute_plan_appends_the_approved_plan_as_a_stable_runtime_event() { fn execute_plan_appends_the_approved_plan_as_a_stable_runtime_event() {
let execute = pb::ExecutePlanAction { let execute = pb::ExecutePlanAction {
@@ -648,8 +789,8 @@ mod tests {
..Default::default() ..Default::default()
}; };
let first = action("request-one", &request).unwrap(); let first = action(&request).unwrap();
let second = action("request-two", &request).unwrap(); let second = action(&request).unwrap();
assert_eq!(first.mode, pb::AgentMode::Agent as i32); assert_eq!(first.mode, pb::AgentMode::Agent as i32);
assert!(first.starts_turn); assert!(first.starts_turn);
assert_eq!(first.event_id, second.event_id); assert_eq!(first.event_id, second.event_id);
+58 -3
View File
@@ -10,6 +10,7 @@ use crate::{
proto::agent::v1 as pb, proto::agent::v1 as pb,
}, },
model::{CanonicalMessage, MessageContent, Origin, Role}, model::{CanonicalMessage, MessageContent, Origin, Role},
store::BlobId,
Error, Result, Error, Result,
}; };
@@ -80,12 +81,66 @@ pub async fn compile(
compiler: &PromptCompiler, compiler: &PromptCompiler,
blobs: &BlobSynchronizer, blobs: &BlobSynchronizer,
) -> Result<CanonicalMessage> { ) -> Result<CanonicalMessage> {
let time = Time::now( let timestamp = Time::now(
request_context request_context
.env .env
.as_ref() .as_ref()
.map(|env| env.time_zone.as_str()), .map(|env| env.time_zone.as_str()),
)?; )?
.timestamp;
compile_with_timestamp(
event_id,
mode,
user,
request_context,
action_context,
timestamp,
compiler,
blobs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn user_event_id(
input_id: &str,
mode: Mode,
user: &pb::UserMessage,
request_context: &pb::RequestContext,
action_context: &str,
projected_request_context: Option<&MessageContent>,
compiler: &PromptCompiler,
blobs: &BlobSynchronizer,
) -> Result<String> {
let runtime = compile_with_timestamp(
"identity".into(),
mode,
user,
request_context,
action_context,
String::new(),
compiler,
blobs,
)
.await?;
let semantic = serde_json::to_vec(&(projected_request_context, runtime.content))?;
Ok(format!(
"{input_id}:{}",
BlobId::digest(&semantic).to_base64()
))
}
#[allow(clippy::too_many_arguments)]
async fn compile_with_timestamp(
event_id: String,
mode: Mode,
user: &pb::UserMessage,
request_context: &pb::RequestContext,
action_context: &str,
timestamp: String,
compiler: &PromptCompiler,
blobs: &BlobSynchronizer,
) -> Result<CanonicalMessage> {
let mut values = BTreeMap::from([ let mut values = BTreeMap::from([
("OPEN_FILES", section(open_files(user))), ("OPEN_FILES", section(open_files(user))),
( (
@@ -98,7 +153,7 @@ pub async fn compile(
), ),
), ),
("ACTION_CONTEXT", section(action_context.to_string())), ("ACTION_CONTEXT", section(action_context.to_string())),
("TIMESTAMP", time.timestamp), ("TIMESTAMP", timestamp),
("USER_QUERY", user.text.clone()), ("USER_QUERY", user.text.clone()),
("DEBUG_SERVER_ENDPOINT", String::new()), ("DEBUG_SERVER_ENDPOINT", String::new()),
("DEBUG_LOG_PATH", String::new()), ("DEBUG_LOG_PATH", String::new()),

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