Compare commits

...
8 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
45 changed files with 1176 additions and 306 deletions
+12 -2
View File
@@ -219,8 +219,18 @@ jobs:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
gh release download "v${VERSION}" --pattern latest.json --dir tauri-update
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" > tauri-update/release.json
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 \
Generated
+101 -3
View File
@@ -926,7 +926,7 @@ dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"axum",
"cursor-server",
@@ -1905,6 +1905,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1912,7 +1921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1926,6 +1935,12 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -2727,6 +2742,22 @@ dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-tungstenite"
version = "0.30.0"
@@ -3640,6 +3671,23 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -4038,12 +4086,49 @@ dependencies = [
"libc",
]
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -5043,9 +5128,11 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5056,6 +5143,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -6924,6 +7012,16 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
+2 -32
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.3",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
@@ -18,13 +18,11 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
@@ -427,12 +425,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
@@ -1817,18 +1809,6 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2998,16 +2978,6 @@
"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": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.3",
"version": "0.1.4",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
@@ -28,13 +28,11 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"sortablejs": "^1.15.7",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
publish = false
@@ -16,6 +16,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command",
"clipboard-manager:allow-write-text",
"autostart:default",
+7 -4
View File
@@ -186,9 +186,12 @@ pub fn run() {
let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings())
.map(|settings| settings.silent_start)
.unwrap_or(false);
let desktop_settings =
tauri::async_runtime::block_on(server.store().desktop_settings())
.unwrap_or_default();
#[cfg(target_os = "macos")]
app.handle()
.set_dock_visibility(desktop_settings.show_dock_icon)?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone();
@@ -206,7 +209,7 @@ pub fn run() {
exiting: AtomicBool::new(false),
});
let window = create_main_window(app.handle(), address)?;
if silent_start && started_by_autostart {
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; keeping the main window hidden");
} else {
window.show()?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.3",
"version": "0.1.4",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+3 -2
View File
@@ -143,6 +143,7 @@ export interface TabSettings {
export interface DesktopSettings {
silent_start: boolean;
show_dock_icon: boolean;
}
export interface OverviewMetrics {
@@ -300,8 +301,8 @@ export const api = {
params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
}
const query = params.size ? `?${params}` : "";
return request<Overview>(`/overview${query}`);
const query = params.toString();
return request<Overview>(`/overview${query ? `?${query}` : ""}`);
},
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
@@ -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,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 { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useRef, type MouseEventHandler } from "react";
import styles from "./EChart.module.scss";
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
use([BarChart, GaugeChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = {
option: EChartsCoreOption;
@@ -1,5 +1,6 @@
import type { ModelInput, ModelType } from "../../api";
import { defaultCustomHeadersText } from "../../utils/modelDefaults";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import { Button } from "../ui/Button";
import { Checkbox } from "../ui/Checkbox";
import { FormField, SecretTextInput, TextInput } from "../ui/FormControls";
@@ -7,6 +8,7 @@ import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { claudeIcon, openAiIcon } from "../ui/icons";
import { CursorPresetChips } from "./CursorPresetChips";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
@@ -53,13 +55,64 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
onDiscover: () => void;
}) {
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const setType = (type: ModelType) => setModel({
type,
openai_endpoint: type === "openai" ? draft.model.openai_endpoint || "/v1/responses" : "",
anthropic_thinking_effort: type === "anthropic" ? draft.model.anthropic_thinking_effort || "xhigh" : null,
});
const setType = (type: ModelType) => {
// 切换协议类型时,若当前地址命中某预设的另一协议端点,自动换到该预设对应协议的端点,
// 避免出现「类型是 Anthropic、URL 却是 OpenAI chat/completions」的错配
const other: ModelType = type === "anthropic" ? "openai" : "anthropic";
const preset = modelPresets.find((candidate) => trimTrailingSlash(presetEndpoint(candidate, other).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()));
const endpoint = preset ? presetEndpoint(preset, type) : null;
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 numberValue = (value: string) => value === "" ? null : Math.trunc(Number(value));
const canDiscover = Boolean(draft.model.base_url.trim() && draft.model.api_key.trim());
// 选中预设后,把该服务商已知的模型 id 并入下拉,方便直接选(仍可用「获取模型」发现)
const presetModelOptions = modelPresets
.filter((preset) => trimTrailingSlash(presetEndpoint(preset, draft.model.type).baseUrl) === trimTrailingSlash(draft.model.base_url.trim()))
.flatMap((preset) => preset.models.map((item) => item.model_id));
const combinedOptions = [...new Set([...modelOptions, ...presetModelOptions])];
const applyPreset = (preset: ModelPreset) => {
const endpoint = presetEndpoint(preset, draft.model.type);
const first = preset.models[0];
// 切到别家服务商时清空 API Key(不同家的 Key 不能串用);同一家内切换协议则保留
const currentBase = trimTrailingSlash(draft.model.base_url.trim());
const sameProvider = [preset.endpoints.anthropic, preset.endpoints.openai]
.some((candidate) => trimTrailingSlash(candidate.baseUrl) === currentBase);
onChange({
...draft,
model: {
...draft.model,
base_url: endpoint.baseUrl,
use_full_url: endpoint.useFullUrl,
openai_endpoint: draft.model.type === "openai" ? endpoint.openaiEndpoint : draft.model.openai_endpoint,
custom_headers_enabled: endpoint.customHeaders !== null,
custom_headers: endpoint.customHeaders ? { ...endpoint.customHeaders } : {},
api_key: sameProvider ? draft.model.api_key : "",
model_id: first?.model_id ?? draft.model.model_id,
display_name: first?.display_name ?? draft.model.display_name,
tooltip_data: !draft.model.tooltip_data.trim() || draft.model.tooltip_data === t("备注") ? preset.name : draft.model.tooltip_data,
context_window_tokens: first?.context_window_tokens ?? draft.model.context_window_tokens,
...(draft.model.type === "openai"
? { max_completion_tokens: first?.max_output_tokens ?? draft.model.max_completion_tokens }
: { anthropic_max_tokens: first?.max_output_tokens ?? draft.model.anthropic_max_tokens }),
},
customHeadersText: endpoint.customHeaders ? JSON.stringify(endpoint.customHeaders, null, 2) : draft.customHeadersText,
});
};
const requestUrlPlaceholder = draft.model.use_full_url
? draft.model.type === "anthropic"
? "https://api.anthropic.com/v1/messages"
@@ -71,6 +124,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
: "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}>
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
{ value: "openai", label: "OpenAI", icon: openAiIcon },
@@ -87,7 +141,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
</div>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox 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>
@@ -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>;
}
@@ -1,18 +1,22 @@
@use "../../styles/typography" as type;
.root {
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
position: relative;
width: 132px;
width: 200px;
max-width: 100%;
height: 82px;
overflow: hidden;
align-self: center;
flex: 0 0 auto;
}
.canvas {
width: 100% !important;
height: 100% !important;
.chart {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 140px;
pointer-events: none;
}
.label {
@@ -1,75 +1,62 @@
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
import { useMemo } from "react";
import { Doughnut } from "react-chartjs-2";
import type { EChartsCoreOption } from "echarts/core";
import { useEffect, useMemo, useState } from "react";
import { EChart } from "../charts/EChart";
import styles from "./CacheHitRateChart.module.scss";
ChartJS.register(ArcElement, Tooltip);
const valueColor = "#40c463";
const trackColor = "rgba(139, 148, 158, 0.20)";
type SegmentRadius = number | {
outerStart: number;
outerEnd: number;
innerStart: number;
innerEnd: number;
};
function chartColor(context: ScriptableContext<"doughnut">) {
const styles = getComputedStyle(context.chart.canvas);
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
return styles.getPropertyValue(variable).trim();
}
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
const radius = 5;
if (percentage <= 0) {
return dataIndex === 1
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
if (percentage >= 100) {
return dataIndex === 0
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
return dataIndex === 0
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
}
const options: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: { duration: 450 },
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
};
export function CacheHitRateChart({ rate }: { rate: number }) {
export function CacheHitRateChart({ rate, animationKey = 0 }: { rate: number; animationKey?: number }) {
const finiteRate = Number.isFinite(rate) ? rate : 0;
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
const [displayedPercentage, setDisplayedPercentage] = useState(0);
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
const data = useMemo(() => ({
labels: [t("命中"), t("未命中")],
datasets: [{
data: [percentage, Math.max(0, 100 - percentage)],
backgroundColor: chartColor,
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
useEffect(() => {
setDisplayedPercentage(0);
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => setDisplayedPercentage(percentage));
});
return () => cancelAnimationFrame(frame);
}, [animationKey, percentage]);
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 })}>
<Doughnut className={styles.canvas} data={data} options={options} />
<EChart className={styles.chart} option={option} />
<div className={styles.label}>{label}</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 outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
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("调用统计")}>
<article className={styles.metric}>
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} animationKey={refreshVersion} />
</article>
<article className={styles.metric}>
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
@@ -1,10 +1,12 @@
import { useEffect, useState } from "react";
import {
currentAppVersion,
hasDockVisibilitySetting,
hasNativeAppLifecycle,
readAutostart,
readSilentStart,
readDesktopSettings,
writeAutostart,
writeDockIconVisibility,
writeSilentStart,
} from "../../native/appLifecycle";
import { updateStore, useUpdateStore } from "../../store/updateStore";
@@ -17,12 +19,14 @@ import styles from "./AppLifecycleSettingsCard.module.scss";
export function AppLifecycleSettingsCard() {
const message = useMessage();
const native = hasNativeAppLifecycle();
const dockVisibilitySetting = hasDockVisibilitySetting();
const { availableVersion, checking, installing } = useUpdateStore();
const [version, setVersion] = useState("…");
const [autostart, setAutostart] = useState(false);
const [loadingAutostart, setLoadingAutostart] = useState(native);
const [silentStart, setSilentStart] = useState(false);
const [loadingSilentStart, setLoadingSilentStart] = useState(native);
const [dockIconVisible, setDockIconVisible] = useState(true);
const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native);
useEffect(() => {
let disposed = false;
@@ -32,10 +36,17 @@ export function AppLifecycleSettingsCard() {
.then((enabled) => { if (!disposed) setAutostart(enabled); })
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
.finally(() => { if (!disposed) setLoadingAutostart(false); });
void readSilentStart()
.then((silent) => { if (!disposed) setSilentStart(silent); })
void readDesktopSettings()
.then((settings) => {
if (disposed) return;
setSilentStart(settings.silent_start);
setDockIconVisible(settings.show_dock_icon);
})
.catch(() => {})
.finally(() => { if (!disposed) setLoadingSilentStart(false); });
.finally(() => {
if (disposed) return;
setLoadingDesktopSettings(false);
});
}
return () => { disposed = true; };
}, [message, native]);
@@ -55,14 +66,27 @@ export function AppLifecycleSettingsCard() {
const toggleSilentStart = async (enabled: boolean) => {
try {
setLoadingSilentStart(true);
setLoadingDesktopSettings(true);
await writeSilentStart(enabled);
setSilentStart(await readSilentStart());
setSilentStart(enabled);
message(enabled ? t("已开启静默启动") : t("已关闭静默启动"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingSilentStart(false);
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);
}
};
@@ -103,11 +127,23 @@ export function AppLifecycleSettingsCard() {
</div>
<Switch
checked={silentStart}
disabled={!native || loadingSilentStart}
disabled={!native || loadingDesktopSettings}
label={t("静默启动")}
onChange={(enabled) => void toggleSilentStart(enabled)}
/>
</div>}
{dockVisibilitySetting && <div className={styles.row}>
<div>
<strong>{t("在 Dock 栏显示")}</strong>
<small>{t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")}</small>
</div>
<Switch
checked={dockIconVisible}
disabled={loadingDesktopSettings}
label={t("在 Dock 栏显示")}
onChange={(visible) => void toggleDockIcon(visible)}
/>
</div>}
<div className={styles.row}>
<div>
<strong>{t("软件更新")}</strong>
+137 -91
View File
@@ -33,7 +33,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 142,
"column": 40
}
]
@@ -159,7 +159,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 118,
"line": 172,
"column": 16
},
{
@@ -369,7 +369,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 105,
"line": 159,
"column": 16
},
{
@@ -434,8 +434,8 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 90,
"column": 283
"line": 144,
"column": 286
}
]
},
@@ -446,12 +446,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 79,
"line": 133,
"column": 59
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 79,
"line": 133,
"column": 124
}
]
@@ -516,7 +516,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 120,
"line": 156,
"column": 27
}
]
@@ -661,17 +661,17 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 148,
"column": 49
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 96,
"line": 150,
"column": 50
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 99,
"line": 153,
"column": 50
}
]
@@ -735,7 +735,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 72,
"line": 96,
"column": 29
}
]
@@ -759,7 +759,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 90,
"line": 144,
"column": 42
}
]
@@ -773,7 +773,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 116,
"line": 152,
"column": 13
}
]
@@ -809,7 +809,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 101,
"line": 155,
"column": 27
},
{
@@ -919,7 +919,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 91,
"line": 145,
"column": 42
}
]
@@ -1095,18 +1095,6 @@
}
]
},
"393df9bb13ea4900": {
"source": "命中",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/metrics/CacheHitRateChart.tsx",
"line": 60,
"column": 14
}
]
},
"393e1241552b1870": {
"source": "请求",
"kind": "text",
@@ -1196,6 +1184,18 @@
}
]
},
"3b67824289b5fa1e": {
"source": "已隐藏 Dock 栏图标",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 85,
"column": 45
}
]
},
"3c94b4c75940c178": {
"source": "输入 Token",
"kind": "text",
@@ -1392,7 +1392,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 102,
"line": 126,
"column": 17
}
]
@@ -1432,7 +1432,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 115,
"line": 151,
"column": 13
}
]
@@ -1478,7 +1478,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 148,
"column": 25
},
{
@@ -1524,7 +1524,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 139,
"column": 145
}
]
@@ -1553,7 +1553,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 143,
"line": 197,
"column": 41
}
]
@@ -1707,12 +1707,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 96,
"line": 150,
"column": 27
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 99,
"line": 153,
"column": 27
},
{
@@ -1741,7 +1741,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 146,
"column": 69
}
]
@@ -1868,7 +1868,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 48,
"line": 59,
"column": 40
}
]
@@ -2004,7 +2004,7 @@
"refs": [
{
"file": "api.ts",
"line": 314,
"line": 315,
"column": 43
}
]
@@ -2016,7 +2016,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 113,
"line": 149,
"column": 18
}
]
@@ -2095,6 +2095,23 @@
}
]
},
"680680288a6d2ad2": {
"source": "在 Dock 栏显示",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 137,
"column": 18
},
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 143,
"column": 16
}
]
},
"68ad603fafe4e0d6": {
"source": "导入旧版配置",
"kind": "text",
@@ -2189,7 +2206,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 72,
"line": 96,
"column": 78
}
]
@@ -2280,7 +2297,7 @@
"refs": [
{
"file": "api.ts",
"line": 309,
"line": 310,
"column": 43
}
]
@@ -2316,7 +2333,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 112,
"line": 166,
"column": 16
},
{
@@ -2395,7 +2412,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 124,
"line": 160,
"column": 37
}
]
@@ -2407,7 +2424,7 @@
"refs": [
{
"file": "api.ts",
"line": 258,
"line": 259,
"column": 21
}
]
@@ -2434,8 +2451,8 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 90,
"column": 295
"line": 144,
"column": 298
}
]
},
@@ -2446,12 +2463,12 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 89,
"line": 113,
"column": 18
},
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 95,
"line": 119,
"column": 16
}
]
@@ -2475,7 +2492,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 61,
"line": 72,
"column": 25
}
]
@@ -2538,7 +2555,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 139,
"column": 115
}
]
@@ -2620,7 +2637,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 79,
"line": 133,
"column": 76
}
]
@@ -2793,12 +2810,12 @@
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 100,
"line": 154,
"column": 27
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 100,
"line": 154,
"column": 58
}
]
@@ -2810,7 +2827,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 61,
"line": 72,
"column": 40
}
]
@@ -2896,7 +2913,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 86,
"line": 110,
"column": 29
}
]
@@ -2908,12 +2925,12 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 101,
"line": 125,
"column": 18
},
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 107,
"line": 131,
"column": 16
}
]
@@ -3079,7 +3096,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"line": 140,
"column": 61
}
]
@@ -3118,7 +3135,7 @@
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 91,
"line": 145,
"column": 25
}
]
@@ -3142,7 +3159,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 48,
"line": 59,
"column": 25
}
]
@@ -3154,7 +3171,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 146,
"column": 118
}
]
@@ -3166,7 +3183,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 120,
"line": 156,
"column": 39
}
]
@@ -3199,6 +3216,18 @@
}
]
},
"aecb952b1e6cce36": {
"source": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 138,
"column": 17
}
]
},
"aee88743413144a2": {
"source": "刷新",
"kind": "text",
@@ -3308,6 +3337,18 @@
}
]
},
"b6725f218ebaef26": {
"source": "已显示 Dock 栏图标",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 85,
"column": 25
}
]
},
"b75a46aad3e7c132": {
"source": "非缓存输入:{tokens}",
"kind": "template",
@@ -3411,7 +3452,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 91,
"line": 145,
"column": 99
}
]
@@ -3493,7 +3534,7 @@
"refs": [
{
"file": "components/metrics/CacheHitRateChart.tsx",
"line": 71,
"line": 58,
"column": 62
}
]
@@ -3548,12 +3589,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 97,
"line": 151,
"column": 27
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 97,
"line": 151,
"column": 58
}
]
@@ -3625,7 +3666,7 @@
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 90,
"line": 144,
"column": 25
}
]
@@ -3695,7 +3736,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 101,
"line": 155,
"column": 50
}
]
@@ -3724,7 +3765,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 90,
"line": 114,
"column": 17
}
]
@@ -3736,7 +3777,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 139,
"column": 70
}
]
@@ -3758,6 +3799,18 @@
}
]
},
"d58c88688e1a949d": {
"source": "常用预设",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorPresetChips.tsx",
"line": 8,
"column": 37
}
]
},
"d60669bb26a22f5d": {
"source": "留空使用默认值",
"kind": "text",
@@ -3765,17 +3818,17 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 148,
"column": 121
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 96,
"line": 150,
"column": 122
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 99,
"line": 153,
"column": 122
}
]
@@ -3787,7 +3840,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 101,
"line": 155,
"column": 137
}
]
@@ -3852,7 +3905,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 107,
"column": 88
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 146,
"column": 54
}
]
@@ -4089,7 +4147,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 139,
"column": 54
}
]
@@ -4159,12 +4217,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 75,
"line": 129,
"column": 25
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 75,
"line": 129,
"column": 55
}
]
@@ -4306,18 +4364,6 @@
}
]
},
"f3a76d896853c1df": {
"source": "未命中",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/metrics/CacheHitRateChart.tsx",
"line": 60,
"column": 23
}
]
},
"f4694c46b1e19602": {
"source": "最终请求类型",
"kind": "text",
@@ -4476,7 +4522,7 @@
"refs": [
{
"file": "components/settings/AppLifecycleSettingsCard.tsx",
"line": 124,
"line": 160,
"column": 25
}
]
+7 -4
View File
@@ -71,7 +71,6 @@
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
"378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"393df9bb13ea4900": "Hit",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
@@ -79,6 +78,7 @@
"3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all",
"3a8c76b2ce785f96": "Review and import",
"3b67824289b5fa1e": "Dock icon hidden",
"3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language",
@@ -143,6 +143,7 @@
"656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"680680288a6d2ad2": "Show in Dock",
"68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time",
@@ -151,7 +152,7 @@
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}",
"72644ec4389da2f7": "Flat",
"72644ec4389da2f7": "Default layout",
"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.",
"788db1cfec2a3db5": "Theme",
@@ -167,7 +168,7 @@
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83c4efccd9a6bf69": "Connectivity tests cancelled: {successful} succeeded, {failed} failed",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
@@ -222,12 +223,14 @@
"ac69f68b7010ec79": "Download and install",
"ad6a60ee93d3ba3e": "Loading call details…",
"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",
"b06325c5660f0c29": "Direct",
"b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.",
"b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
"b6725f218ebaef26": "Dock icon shown",
"b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)",
@@ -260,6 +263,7 @@
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…",
"d58c88688e1a949d": "Presets",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d86fa42c3848c680": "Use system proxy",
@@ -302,7 +306,6 @@
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f2bdc88464c51c2e": "Show API Key",
"f3a76d896853c1df": "Miss",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
+5 -2
View File
@@ -71,7 +71,6 @@
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"393df9bb13ea4900": "命中",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
@@ -79,6 +78,7 @@
"3a3f595df70ec8ff": "清理存储空间",
"3a5040b68abf75f9": "全选",
"3a8c76b2ce785f96": "查看并导入",
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
"3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言",
@@ -143,6 +143,7 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}",
"680680288a6d2ad2": "在 Dock 栏显示",
"68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间",
@@ -222,12 +223,14 @@
"ac69f68b7010ec79": "下载并安装",
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
"b6725f218ebaef26": "已显示 Dock 栏图标",
"b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)",
@@ -260,6 +263,7 @@
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d58c88688e1a949d": "常用预设",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理",
@@ -302,7 +306,6 @@
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f2bdc88464c51c2e": "显示 API Key",
"f3a76d896853c1df": "未命中",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
+1 -10
View File
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom";
import { desktopPlatform } from "../native/platform";
import styles from "./AppFrame.module.scss";
import { AppHeader } from "./AppHeader";
@@ -17,13 +18,3 @@ export function AppFrame() {
</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 appIcon from "../../src-tauri/icons/32x32.png";
import { currentAppVersion } from "../native/appLifecycle";
import type { DesktopPlatform } from "./AppFrame";
import type { DesktopPlatform } from "../native/platform";
import { WindowControls } from "./WindowControls";
import styles from "./AppHeader.module.scss";
+22 -5
View File
@@ -1,14 +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 { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { api } from "../api";
import { api, type DesktopSettings } from "../api";
import { desktopPlatform } from "./platform";
export function hasNativeAppLifecycle(): boolean {
return isTauri();
}
export function hasDockVisibilitySetting(): boolean {
return hasNativeAppLifecycle() && desktopPlatform() === "macos";
}
export async function currentAppVersion(): Promise<string> {
return hasNativeAppLifecycle() ? getVersion() : "dev";
}
@@ -21,12 +26,24 @@ export async function writeAutostart(enabled: boolean): Promise<void> {
await (enabled ? enable() : disable());
}
export async function readSilentStart(): Promise<boolean> {
return (await api.desktopSettings()).silent_start;
export async function readDesktopSettings(): Promise<DesktopSettings> {
return api.desktopSettings();
}
export async function writeSilentStart(silentStart: boolean): Promise<void> {
await api.setDesktopSettings({ silent_start: silentStart });
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> {
+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";
}
+1 -1
View File
@@ -118,7 +118,7 @@ export function HomePage() {
{
key: "metrics",
estimatedHeight: 130,
content: <HomeMetrics data={metrics} />,
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
},
{
+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> {
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");
fs::create_dir_all(&directory).map_err(|error| Error::io(&directory, error))?;
let model = Self::model_path(cache_root);
let tokenizer = directory.join("tokenizer.json");
ensure_asset(&model, MODEL_URL, MODEL_SHA256)?;
ensure_asset(&tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?;
ensure_asset(client, &model, MODEL_URL, MODEL_SHA256)?;
ensure_asset(client, &tokenizer, TOKENIZER_URL, TOKENIZER_SHA256)?;
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 {
return Ok(());
}
let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
let response = reqwest::blocking::Client::builder()
.build()
.map_err(|error| Error::ModelAsset(error.to_string()))?
let response = client
.get(url)
.send()
.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 {
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 cached = { EMBEDDERS.lock().get(&model_path).cloned() };
let embedder = if let Some(embedder) = cached {
embedder
} 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)?);
EMBEDDERS
.lock()
+10 -6
View File
@@ -36,22 +36,26 @@ export function normalizeTauriUpdate(manifest, release, repository, version) {
throw new Error("updater manifest has no platforms");
}
const assetsByApiUrl = new Map();
const assetsBySourceUrl = new Map();
const publicAssetUrls = new Set();
for (const asset of release.assets ?? []) {
if (!asset?.id || !asset?.browser_download_url) continue;
assetsByApiUrl.set(
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}`,
asset.browser_download_url,
publicUrl,
);
publicAssetUrls.add(asset.browser_download_url);
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 = assetsByApiUrl.get(entry.url) ?? entry.url;
const publicUrl = assetsBySourceUrl.get(entry.url) ?? entry.url;
if (!publicAssetUrls.has(publicUrl)) {
throw new Error(`updater platform ${platform} references an unknown release asset`);
}
+1 -1
View File
@@ -33,7 +33,7 @@ parking_lot = "0.12"
pem = "3"
prost = "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"
rcgen = { version = "0.14", features = ["aws_lc_rs", "pem", "x509-parser"] }
scraper = "0.24"
+193 -12
View File
@@ -279,10 +279,7 @@ impl ControlService {
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(CancellationToken::new)
.clone()
tests.entry(test_id.to_owned()).or_default().clone()
};
cancellation.cancel();
}
@@ -708,19 +705,95 @@ fn model_discovery_url(base_url: &str) -> Result<Url> {
"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_fragment(None);
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(
client: &reqwest::Client,
base_url: &str,
api_key: &str,
custom_headers: &serde_json::Value,
) -> 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() {
request = request.bearer_auth(api_key);
}
@@ -742,12 +815,28 @@ async fn anthropic_models(
base_url: &str,
api_key: &str,
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>> {
let mut after_id = None::<String>;
let mut found = BTreeSet::new();
loop {
let mut request = client
.get(model_discovery_url(base_url)?)
.get(url.clone())
.query(&[("limit", "100")])
.header("anthropic-version", "2023-06-01");
if !api_key.is_empty() {
@@ -846,7 +935,98 @@ mod tests {
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 {
invocation: Arc<Mutex<Option<ModelInvocation>>>,
@@ -1010,12 +1190,12 @@ mod tests {
}
#[test]
fn model_discovery_url_uses_only_the_provider_origin() {
fn model_discovery_url_keeps_provider_path_prefix() {
assert_eq!(
super::model_discovery_url("https://example.com:8443/arbitrary/v1/chat/completions")
.unwrap()
.as_str(),
"https://example.com:8443/v1/models"
"https://example.com:8443/arbitrary/v1/models"
);
}
@@ -1044,7 +1224,7 @@ mod tests {
let (sender, mut requests) = tokio::sync::mpsc::unbounded_channel();
let app = axum::Router::new()
.route("/v1/models", axum::routing::get(models))
.route("/custom/models", axum::routing::get(models))
.with_state(sender);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
@@ -1081,7 +1261,8 @@ mod tests {
assert_eq!(result.models, vec!["model-a"]);
let (method, uri, headers, body) = requests.recv().await.unwrap();
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!(headers.get(axum::http::header::USER_AGENT).is_none());
assert_eq!(headers.get("x-tenant").unwrap(), "tenant-a");
+5 -1
View File
@@ -48,7 +48,11 @@ impl CursorActor {
let tool_runtime = CursorToolRuntime::default();
let context_sync =
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));
loop {
let command = match receiver.recv().await {
+4 -6
View File
@@ -478,9 +478,9 @@ pub fn dynamic_mcp(
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
.as_object_mut()
.as_object()
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
match schema.get("type") {
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 {
return Err(invalid_mcp_parameters(tool_name));
}
schema.insert("type".into(), Value::String("object".into()));
Ok(parameters)
}
@@ -614,7 +613,7 @@ mod tests {
}
#[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!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
@@ -655,8 +654,7 @@ mod tests {
.get("cursor-app-control-move_agent_to_cloned_root")
.unwrap();
assert_eq!(definition.parameters["type"], "object");
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
assert_eq!(definition.parameters, original_schema);
assert_eq!(
wire.input_schema_json.as_deref(),
Some(original_json.as_str())
+3 -1
View File
@@ -10,6 +10,7 @@ use std::collections::BTreeMap;
use crate::{
cursor::proto::agent::v1 as pb,
model::ToolCall,
store::Store,
web::{WebFetch, WebSearch},
Error, Result,
};
@@ -36,6 +37,7 @@ pub(super) async fn start(
message_index: usize,
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
context: &ExecContext,
store: Option<&Store>,
) -> Result<ToolStart> {
if let Some(definition) = dynamic_mcp.get(&call.name) {
return exec::start_dynamic(runtime, call, definition, context).await;
@@ -57,7 +59,7 @@ pub(super) async fn start(
| "generateimage" => interaction::start(runtime, call).await,
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
"awaitshell" => await_shell::start(runtime, results, call, context).await,
"semblesearch" | "semblefindrelated" => semble::start(results, call),
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
}
}
+23 -12
View File
@@ -7,7 +7,7 @@ use serde::Deserialize;
use serde_json::Value;
use tokio::sync::OnceCell;
use crate::{model::ToolCall, Error, Result};
use crate::{model::ToolCall, store::Store, Error, Result};
use super::ToolStart;
use crate::cursor::tools::{
@@ -52,7 +52,11 @@ struct FindRelatedArguments {
content: ContentSelection,
}
pub(super) fn start(results: &ToolResultSender, call: &ToolCall) -> Result<ToolStart> {
pub(super) fn start(
results: &ToolResultSender,
call: &ToolCall,
store: Option<Store>,
) -> Result<ToolStart> {
let operation = match super::normalized(&call.name).as_str() {
"semblesearch" => Operation::Search(serde_json::from_value(call.arguments.clone())?),
"semblefindrelated" => {
@@ -69,7 +73,7 @@ pub(super) fn start(results: &ToolResultSender, call: &ToolCall) -> Result<ToolS
let results = results.clone();
let started_at_ms = now_ms();
tokio::spawn(async move {
let output = execute(operation).await;
let output = execute(operation, store).await;
match result::semble(&call, started_at_ms, output) {
Ok(completion) => results.send(completion),
Err(error) => results.send_error(error),
@@ -86,8 +90,8 @@ enum Operation {
FindRelated(FindRelatedArguments),
}
async fn execute(operation: Operation) -> std::result::Result<Value, String> {
let engine = engine().await.map_err(|error| error.to_string())?;
async fn execute(operation: Operation, store: Option<Store>) -> std::result::Result<Value, String> {
let engine = engine(store).await.map_err(|error| error.to_string())?;
tokio::task::spawn_blocking(move || match operation {
Operation::Search(arguments) => engine
.search(SearchRequest {
@@ -114,14 +118,21 @@ async fn execute(operation: Operation) -> std::result::Result<Value, String> {
.map_err(|error| error.to_string())
}
async fn engine() -> Result<Arc<SearchEngine>> {
async fn engine(store: Option<Store>) -> Result<Arc<SearchEngine>> {
ENGINE
.get_or_try_init(|| async {
tokio::task::spawn_blocking(|| SearchEngine::load_default(SembleConfig::default()))
.await
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))?
.map(Arc::new)
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))
.get_or_try_init(|| async move {
let builder = match store {
Some(store) => crate::network::blocking_client_builder(&store).await?,
None => reqwest::blocking::Client::builder().use_native_tls(),
};
tokio::task::spawn_blocking(move || {
let client = builder.build()?;
SearchEngine::load_default_with_client(SembleConfig::default(), &client)
.map(Arc::new)
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))
})
.await
.map_err(|error| Error::Config(format!("load Semble search engine: {error}")))?
})
.await
.cloned()
+19 -4
View File
@@ -17,6 +17,7 @@ mod tests;
use crate::{
model::{CanonicalMessage, MessageContent, Role, ToolCall},
store::Store,
web::{WebFetch, WebSearch},
Error, Result,
};
@@ -32,6 +33,7 @@ pub struct ToolDispatcher {
results: ToolResultSender,
search: WebSearch,
fetch: WebFetch,
store: Option<Store>,
edit_schedule: Arc<Mutex<EditSchedule>>,
}
@@ -55,15 +57,27 @@ pub enum ClientToolEvent {
impl ToolDispatcher {
pub fn new(runtime: CursorToolRuntime) -> Self {
let (results, _) = result::tool_result_channel();
Self::with_results(runtime, results)
}
pub fn with_results(runtime: CursorToolRuntime, results: ToolResultSender) -> Self {
Self {
runtime,
results,
search: WebSearch::built_in(),
fetch: WebFetch::built_in(),
store: None,
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
}
}
pub fn with_results(
runtime: CursorToolRuntime,
results: ToolResultSender,
store: Store,
) -> Self {
Self {
runtime,
results,
search: WebSearch::managed(store.clone()),
fetch: WebFetch::managed(store.clone()),
store: Some(store),
edit_schedule: Arc::new(Mutex::new(EditSchedule::default())),
}
}
@@ -165,6 +179,7 @@ impl ToolDispatcher {
message_index,
dynamic_mcp,
context,
self.store.as_ref(),
)
.await?;
messages.extend(started.messages);
+113 -1
View File
@@ -4,7 +4,9 @@ use crate::{store::Store, Result};
pub async fn client_builder(store: &Store) -> Result<reqwest::ClientBuilder> {
let settings = store.proxy_settings_secret().await?;
let mut builder = reqwest::Client::builder();
// Use the platform TLS stack for compatibility with provider gateways that
// only offer legacy TLS 1.2 cipher suites unsupported by rustls.
let mut builder = reqwest::Client::builder().use_native_tls();
if settings.mode.is_custom() {
let mut proxy = reqwest::Proxy::all(&settings.address)?;
if settings.auth_enabled {
@@ -18,3 +20,113 @@ pub async fn client_builder(store: &Store) -> Result<reqwest::ClientBuilder> {
pub async fn client(store: &Store) -> Result<reqwest::Client> {
Ok(client_builder(store).await?.build()?)
}
pub async fn blocking_client_builder(store: &Store) -> Result<reqwest::blocking::ClientBuilder> {
let settings = store.proxy_settings_secret().await?;
let mut builder = reqwest::blocking::Client::builder().use_native_tls();
if settings.mode.is_custom() {
let mut proxy = reqwest::Proxy::all(&settings.address)?;
if settings.auth_enabled {
proxy = proxy.basic_auth(&settings.username, &settings.password);
}
builder = builder.no_proxy().proxy(proxy);
}
Ok(builder)
}
#[cfg(test)]
mod tests {
use std::{
io::{BufRead, BufReader, Write},
net::TcpListener,
sync::mpsc,
thread,
};
use crate::store::{ProxyMode, ProxySettingsInput};
use super::*;
#[tokio::test]
async fn custom_proxy_applies_to_async_and_blocking_clients() {
let directory = tempfile::tempdir().unwrap();
let database_url = format!("sqlite://{}", directory.path().join("test.db").display());
let store = Store::connect(&database_url).await.unwrap();
let (proxy_address, requests, proxy) = proxy_server(2);
store
.set_proxy_settings(ProxySettingsInput {
mode: ProxyMode::Custom,
address: proxy_address,
auth_enabled: true,
username: "proxy-user".into(),
password: Some("proxy-password".into()),
})
.await
.unwrap();
client(&store)
.await
.unwrap()
.get("http://provider.invalid/async")
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let blocking = blocking_client_builder(&store).await.unwrap();
tokio::task::spawn_blocking(move || {
blocking
.build()
.unwrap()
.get("http://provider.invalid/blocking")
.send()
.unwrap()
.error_for_status()
.unwrap();
})
.await
.unwrap();
let requests = [requests.recv().unwrap(), requests.recv().unwrap()];
assert!(requests
.iter()
.any(|request| request.starts_with("GET http://provider.invalid/async ")));
assert!(requests
.iter()
.any(|request| request.starts_with("GET http://provider.invalid/blocking ")));
assert!(requests.iter().all(|request| request
.to_ascii_lowercase()
.contains("\r\nproxy-authorization: basic ")));
proxy.join().unwrap();
}
fn proxy_server(
expected_requests: usize,
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let (sender, receiver) = mpsc::channel();
let server = thread::spawn(move || {
for stream in listener.incoming().take(expected_requests) {
let mut stream = stream.unwrap();
let mut request = String::new();
let mut reader = BufReader::new(stream.try_clone().unwrap());
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
request.push_str(&line);
if line == "\r\n" {
break;
}
}
sender.send(request).unwrap();
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
)
.unwrap();
}
});
(address, receiver, server)
}
}
+40 -1
View File
@@ -47,10 +47,25 @@ pub struct TabSettings {
pub address: String,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct DesktopSettings {
#[serde(default)]
pub silent_start: bool,
#[serde(default = "default_true")]
pub show_dock_icon: bool,
}
impl Default for DesktopSettings {
fn default() -> Self {
Self {
silent_start: false,
show_dock_icon: true,
}
}
}
fn default_true() -> bool {
true
}
impl TabSettings {
@@ -324,6 +339,30 @@ mod tests {
assert_eq!(store.port_settings().await.unwrap(), settings);
}
#[tokio::test]
async fn desktop_settings_show_the_dock_icon_by_default_and_round_trip() {
let store = Store::connect("sqlite::memory:").await.unwrap();
assert_eq!(
store.desktop_settings().await.unwrap(),
DesktopSettings::default()
);
assert_eq!(
serde_json::from_str::<DesktopSettings>(r#"{"silent_start":true}"#).unwrap(),
DesktopSettings {
silent_start: true,
show_dock_icon: true,
}
);
let settings = DesktopSettings {
silent_start: true,
show_dock_icon: false,
};
store.set_desktop_settings(settings).await.unwrap();
assert_eq!(store.desktop_settings().await.unwrap(), settings);
}
#[tokio::test]
async fn proxy_settings_are_write_only_and_preserve_an_unchanged_password() {
let store = Store::connect("sqlite::memory:").await.unwrap();
+24 -3
View File
@@ -2,6 +2,8 @@ use std::{cmp::Ordering, collections::HashMap};
use futures_util::future::join_all;
use crate::store::Store;
use super::{catalog, SearchEngine, SearchHit};
const RRF_K: f64 = 60.0;
@@ -9,10 +11,16 @@ const MAX_RESULTS: usize = 10;
#[derive(Clone)]
pub struct WebSearch {
client: reqwest::Client,
client: SearchClient,
engines: Vec<SearchEngine>,
}
#[derive(Clone)]
enum SearchClient {
Managed(Store),
Direct(reqwest::Client),
}
#[derive(Debug, thiserror::Error)]
#[error("web search failed: {0}")]
pub struct SearchError(String);
@@ -22,13 +30,20 @@ impl WebSearch {
Self::with_engines(catalog::engines())
}
pub(crate) fn managed(store: Store) -> Self {
Self {
client: SearchClient::Managed(store),
engines: catalog::engines(),
}
}
pub fn with_engines<I, E>(engines: I) -> Self
where
I: IntoIterator<Item = E>,
E: Into<SearchEngine>,
{
Self {
client: reqwest::Client::new(),
client: SearchClient::Direct(reqwest::Client::new()),
engines: engines.into_iter().map(Into::into).collect(),
}
}
@@ -42,10 +57,16 @@ impl WebSearch {
if query.is_empty() {
return Err(SearchError("query is empty".into()));
}
let client = match &self.client {
SearchClient::Managed(store) => crate::network::client(store)
.await
.map_err(|error| SearchError(format!("HTTP client failed: {error}")))?,
SearchClient::Direct(client) => client.clone(),
};
let responses = join_all(
self.engines
.iter()
.map(|engine| engine.search(&self.client, query)),
.map(|engine| engine.search(&client, query)),
)
.await;
let mut merged = HashMap::<String, SearchHit>::new();
+25 -1
View File
@@ -14,6 +14,8 @@ use reqwest::{
use tokio::{net::lookup_host, time::timeout};
use url::{Host, Url};
use crate::store::Store;
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
@@ -38,12 +40,27 @@ enum NetworkPolicy {
#[derive(Clone)]
pub struct WebFetch {
network: NetworkPolicy,
client: FetchClient,
}
#[derive(Clone)]
enum FetchClient {
Managed(Store),
Direct,
}
impl WebFetch {
pub fn built_in() -> Self {
Self {
network: NetworkPolicy::PublicOnly,
client: FetchClient::Direct,
}
}
pub(crate) fn managed(store: Store) -> Self {
Self {
network: NetworkPolicy::PublicOnly,
client: FetchClient::Managed(store),
}
}
@@ -51,6 +68,7 @@ impl WebFetch {
pub(crate) fn for_test() -> Self {
Self {
network: NetworkPolicy::Any,
client: FetchClient::Direct,
}
}
@@ -111,7 +129,13 @@ impl WebFetch {
return Err(failure("URL resolves to a non-public address"));
}
let mut builder = reqwest::Client::builder()
let builder = match &self.client {
FetchClient::Managed(store) => crate::network::client_builder(store)
.await
.map_err(|error| failure(format!("HTTP client failed: {error}")))?,
FetchClient::Direct => reqwest::Client::builder().use_native_tls(),
};
let mut builder = builder
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10));
if domain {