mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 12:13:05 +08:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4234da4b4b | ||
|
|
6c758f2b44 | ||
|
|
9cb16e116b | ||
|
|
c5d578c5b1 | ||
|
|
661ce11207 | ||
|
|
477750860f | ||
|
|
02a190489a | ||
|
|
ee915ee760 | ||
|
|
aa68205735 | ||
|
|
14d6a501a0 | ||
|
|
82f3ae8d59 | ||
|
|
31dfa8d295 | ||
|
|
7914f24d83 | ||
|
|
b85cb37898 | ||
|
|
cfc333dde4 | ||
|
|
995a12df44 | ||
|
|
7058d0193d | ||
|
|
eff642fdcf | ||
|
|
2c95d7f836 | ||
|
|
75babb7334 |
@@ -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
@@ -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"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||
|
||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
||||
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
npm --prefix apps/docs run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-docs:
|
||||
npm --prefix apps/docs run dev
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
@@ -20,6 +24,9 @@ dev-desktop:
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-docs:
|
||||
npm --prefix apps/docs run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #141414;
|
||||
}
|
||||
</style>
|
||||
<title>Cursor BYOK 产品演示</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/src/demo/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2
-32
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
||||
"serve": "vite preview",
|
||||
@@ -28,13 +29,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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import type {
|
||||
CallDetail,
|
||||
CursorHarnessStatus,
|
||||
LlmCall,
|
||||
Model,
|
||||
Overview,
|
||||
OverviewTokenUsageBucket,
|
||||
ProxySettings,
|
||||
StatisticsStorage,
|
||||
TabSettings,
|
||||
} from "../api";
|
||||
|
||||
const API_ROOT = "/__byok-api__/api";
|
||||
const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0);
|
||||
|
||||
const models: Model[] = [
|
||||
createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }),
|
||||
createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }),
|
||||
createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }),
|
||||
createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }),
|
||||
createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }),
|
||||
createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }),
|
||||
createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }),
|
||||
createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }),
|
||||
createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }),
|
||||
createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }),
|
||||
];
|
||||
|
||||
const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => {
|
||||
const model = models[index % models.length];
|
||||
const failed = index === 7 || index === 19;
|
||||
return {
|
||||
call_kind: "provider_llm",
|
||||
route: "local_byok",
|
||||
call_id: `mock-call-${String(index + 1).padStart(3, "0")}`,
|
||||
run_id: `mock-run-${Math.floor(index / 3) + 1}`,
|
||||
conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`,
|
||||
provider_call_index: index + 1,
|
||||
model_hash: model.model_hash,
|
||||
provider_type: model.type,
|
||||
provider_url: model.base_url,
|
||||
request_type: model.type === "anthropic" ? "messages" : "responses",
|
||||
request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`,
|
||||
model_id: model.model_id,
|
||||
display_name: model.display_name,
|
||||
reasoning_effort: index % 2 === 0 ? "high" : null,
|
||||
fast: index % 3 === 0,
|
||||
status: failed ? "failed" : "completed",
|
||||
finish_reason: failed ? null : "stop",
|
||||
created_at_ms: FIXED_NOW - index * 3 * 60_000,
|
||||
ttfb_ms: 210 + index * 13,
|
||||
ttft_ms: 370 + index * 17,
|
||||
duration_ms: failed ? 812 : 1_420 + index * 71,
|
||||
input_tokens: 4_800 + index * 337,
|
||||
output_tokens: failed ? 0 : 820 + index * 43,
|
||||
total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380,
|
||||
cache_read_tokens: 3_100 + index * 251,
|
||||
cache_write_tokens: 320 + index * 19,
|
||||
reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null,
|
||||
message_count: 14 + (index % 8),
|
||||
tool_count: 3 + (index % 5),
|
||||
http_status: failed ? 429 : 200,
|
||||
error_kind: failed ? "provider_rate_limit" : null,
|
||||
error_message: failed ? "Mock provider rate limit" : null,
|
||||
detailed: true,
|
||||
};
|
||||
});
|
||||
|
||||
const harnessStatus: CursorHarnessStatus = {
|
||||
platform: "macos",
|
||||
ca: "ready",
|
||||
configured_models: models.length,
|
||||
enabled_models: models.length,
|
||||
integration: "enabled",
|
||||
proxy_url: "http://127.0.0.1:54321",
|
||||
ca_install_command: null,
|
||||
};
|
||||
|
||||
let detailed = true;
|
||||
let portSettings = { proxy_port: 0, service_port: 0 };
|
||||
let proxySettings: ProxySettings = {
|
||||
mode: "system",
|
||||
address: "",
|
||||
auth_enabled: false,
|
||||
username: "",
|
||||
has_password: false,
|
||||
};
|
||||
let tabSettings: TabSettings = { mode: "public", address: "" };
|
||||
let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length };
|
||||
|
||||
export function installDemoApi() {
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async (input, init) => {
|
||||
const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input;
|
||||
const url = new URL(requestUrl, window.location.href);
|
||||
if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init);
|
||||
|
||||
const path = url.pathname.slice(API_ROOT.length) || "/";
|
||||
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
||||
const body = await readBody(input, init);
|
||||
|
||||
if (path === "/ads") return json({ slots: [] });
|
||||
if (path === "/models" && method === "GET") return json(models);
|
||||
if (path === "/models" && method === "POST") return json(models);
|
||||
if (path === "/models/order") return json(models);
|
||||
if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) });
|
||||
if (path === "/models/import-v0049" && method === "GET") {
|
||||
return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] });
|
||||
}
|
||||
if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 });
|
||||
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") {
|
||||
return json({ duration_ms: 1_284, first_text_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." });
|
||||
}
|
||||
if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) {
|
||||
return method === "DELETE" ? empty() : json(models[0]);
|
||||
}
|
||||
if (path === "/overview") return json(createOverview(url.searchParams));
|
||||
if (path === "/llm-calls") return json(calls);
|
||||
if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length)));
|
||||
if (path === "/harness/cursor/status") return json(harnessStatus);
|
||||
if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus);
|
||||
if (path === "/settings/observability" && method === "GET") return json({ detailed });
|
||||
if (path === "/settings/observability") {
|
||||
detailed = Boolean((body as { detailed?: unknown } | null)?.detailed);
|
||||
return json({ detailed });
|
||||
}
|
||||
if (path === "/settings/ports" && method === "GET") return json(portSettings);
|
||||
if (path === "/settings/ports") {
|
||||
portSettings = body as typeof portSettings;
|
||||
return json(portSettings);
|
||||
}
|
||||
if (path === "/settings/storage/statistics" && method === "GET") return json(storage);
|
||||
if (path === "/settings/storage/statistics") {
|
||||
storage = { bytes: 0, call_count: 0, trace_count: 0 };
|
||||
return json(storage);
|
||||
}
|
||||
if (path === "/settings/proxy" && method === "GET") return json(proxySettings);
|
||||
if (path === "/settings/proxy") {
|
||||
const next = body as Partial<ProxySettings>;
|
||||
proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) };
|
||||
return json(proxySettings);
|
||||
}
|
||||
if (path === "/settings/tab" && method === "GET") return json(tabSettings);
|
||||
if (path === "/settings/tab") {
|
||||
tabSettings = body as TabSettings;
|
||||
return json(tabSettings);
|
||||
}
|
||||
if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true });
|
||||
if (path === "/settings/desktop") return json(body);
|
||||
if (path === "/desktop/open-external-url") {
|
||||
const target = (body as { url?: string } | null)?.url;
|
||||
if (target) {
|
||||
const next = new URL(target, window.location.href);
|
||||
if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash;
|
||||
}
|
||||
return empty();
|
||||
}
|
||||
if (path.endsWith("/dismissals")) return empty();
|
||||
|
||||
return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404);
|
||||
};
|
||||
}
|
||||
|
||||
function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: {
|
||||
hash: string;
|
||||
order: number;
|
||||
name: string;
|
||||
type: Model["type"];
|
||||
url: string;
|
||||
modelId: string;
|
||||
endpoint?: string;
|
||||
}): Model {
|
||||
return {
|
||||
model_hash: hash,
|
||||
sort_order: order,
|
||||
display_name: name,
|
||||
type,
|
||||
base_url: url,
|
||||
use_full_url: false,
|
||||
api_key: "demo-key",
|
||||
tooltip_data: `${name} Mock 通道`,
|
||||
model_id: modelId,
|
||||
reasoning_effort: type === "openai" ? "high" : null,
|
||||
openai_endpoint: type === "openai" ? endpoint : "",
|
||||
openai_extra_params_enabled: false,
|
||||
openai_extra_params: {},
|
||||
custom_headers_enabled: false,
|
||||
custom_headers: {},
|
||||
anthropic_extra_params_enabled: false,
|
||||
anthropic_extra_params: {},
|
||||
context_window_tokens: 200_000,
|
||||
max_completion_tokens: type === "openai" ? 32_000 : null,
|
||||
anthropic_max_tokens: type === "anthropic" ? 32_000 : null,
|
||||
anthropic_thinking_effort: type === "anthropic" ? "high" : null,
|
||||
thinking_budget_tokens: null,
|
||||
created_at_ms: FIXED_NOW - order * 86_400_000,
|
||||
updated_at_ms: FIXED_NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function createOverview(params: URLSearchParams): Overview {
|
||||
const start = Number(params.get("start_ms"));
|
||||
const end = Number(params.get("end_ms"));
|
||||
const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000;
|
||||
const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day";
|
||||
const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000;
|
||||
const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step)));
|
||||
const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW);
|
||||
const totals = series.reduce((sum, bucket) => ({
|
||||
input: sum.input + bucket.input_tokens,
|
||||
cacheRead: sum.cacheRead + bucket.cache_read_tokens,
|
||||
cacheWrite: sum.cacheWrite + bucket.cache_write_tokens,
|
||||
output: sum.output + bucket.output_tokens,
|
||||
}), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
|
||||
const llmCalls = Math.max(12, Math.round(count * 5.4));
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
llm_calls: llmCalls,
|
||||
successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)),
|
||||
failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)),
|
||||
token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output,
|
||||
prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite,
|
||||
input_tokens: totals.input,
|
||||
cache_read_tokens: totals.cacheRead,
|
||||
cache_write_tokens: totals.cacheWrite,
|
||||
output_tokens: totals.output,
|
||||
},
|
||||
token_usage_granularity: granularity,
|
||||
token_usage_series: series,
|
||||
};
|
||||
}
|
||||
|
||||
function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const wave = 0.72 + ((index * 17) % 31) / 50;
|
||||
return {
|
||||
bucket_start_ms: end - (count - index) * step,
|
||||
input_tokens: Math.round(18_000 * wave),
|
||||
cache_read_tokens: Math.round(62_000 * wave),
|
||||
cache_write_tokens: Math.round(7_500 * wave),
|
||||
output_tokens: Math.round(12_500 * wave),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createCallDetail(id: string): CallDetail {
|
||||
const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0];
|
||||
return {
|
||||
call,
|
||||
request: {
|
||||
headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" },
|
||||
body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] },
|
||||
byte_count: 8_426,
|
||||
},
|
||||
response_chunks: [
|
||||
{ seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 },
|
||||
{ seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 },
|
||||
],
|
||||
cursor_trace: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise<unknown> {
|
||||
const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null);
|
||||
if (typeof raw !== "string" || raw.length === 0) return null;
|
||||
try { return JSON.parse(raw) as unknown; }
|
||||
catch { return raw; }
|
||||
}
|
||||
|
||||
function json(value: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function empty() {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { installDemoApi } from "./api";
|
||||
|
||||
installDemoApi();
|
||||
document.documentElement.dataset.platform = "macos";
|
||||
localStorage.setItem("cursor-byok.locale", "zh-CN");
|
||||
localStorage.setItem("cursor-byok.theme", "default-dark");
|
||||
|
||||
void import("../index");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,8 +1,9 @@
|
||||
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 { MacTrafficLights } from "./MacTrafficLights";
|
||||
import styles from "./AppHeader.module.scss";
|
||||
|
||||
type AppHeaderProps = {
|
||||
@@ -11,6 +12,7 @@ type AppHeaderProps = {
|
||||
};
|
||||
|
||||
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||
const showMacTrafficLights = !nativeDesktop && platform === "macos";
|
||||
const showNativeUi = nativeDesktop && platform !== "macos";
|
||||
const [version, setVersion] = useState("…");
|
||||
|
||||
@@ -25,6 +27,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||
return <header className={styles.root}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div className={styles.uiLayer}>
|
||||
{showMacTrafficLights && <MacTrafficLights />}
|
||||
{showNativeUi && <>
|
||||
<div className={styles.identity} aria-label="Cursor BYOK">
|
||||
<img src={appIcon} alt="" />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
border: 0.5px solid rgb(0 0 0 / 18%);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.minimize {
|
||||
background: #febc2e;
|
||||
}
|
||||
|
||||
.zoom {
|
||||
background: #28c840;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from "./MacTrafficLights.module.scss";
|
||||
|
||||
export function MacTrafficLights() {
|
||||
return <div className={styles.root} aria-hidden="true">
|
||||
<span className={[styles.light, styles.close].join(" ")} />
|
||||
<span className={[styles.light, styles.minimize].join(" ")} />
|
||||
<span className={[styles.light, styles.zoom].join(" ")} />
|
||||
</div>;
|
||||
}
|
||||
@@ -1,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> {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type DesktopPlatform = "macos" | "windows" | "linux";
|
||||
|
||||
export function desktopPlatform(): DesktopPlatform {
|
||||
const configured = document.documentElement.dataset.platform;
|
||||
if (configured === "macos" || configured === "windows" || configured === "linux") {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const agent = navigator.userAgent;
|
||||
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
|
||||
if (/Windows/.test(agent)) return "windows";
|
||||
return "linux";
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function HomePage() {
|
||||
{
|
||||
key: "metrics",
|
||||
estimatedHeight: 130,
|
||||
content: <HomeMetrics data={metrics} />,
|
||||
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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];
|
||||
@@ -8,5 +8,5 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "plugins/**/*.ts"]
|
||||
"include": ["vite*.config.ts", "plugins/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.ts";
|
||||
|
||||
const desktopRoot = fileURLToPath(new URL("./", import.meta.url));
|
||||
const demoInput = fileURLToPath(new URL("./demo/index.html", import.meta.url));
|
||||
const demoOutput = fileURLToPath(new URL("../docs/public/product-demo", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: desktopRoot,
|
||||
base: "/product-demo/",
|
||||
plugins: [staticI18nPlugin(), react()],
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir: demoOutput,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: demoInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# deps
|
||||
/node_modules
|
||||
|
||||
# generated desktop product demo
|
||||
/public/product-demo
|
||||
|
||||
# generated content
|
||||
.source
|
||||
|
||||
# test & build
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# others
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,48 @@
|
||||
# cursor-byok 文档站
|
||||
|
||||
基于 [Fumadocs](https://fumadocs.dev) 和 Next.js 的独立内容应用。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
apps/docs/
|
||||
├── app/
|
||||
│ ├── (home)/ # Hero 首页与开发者博客
|
||||
│ └── docs/ # 用户文档布局和页面
|
||||
├── components/ # MDX 组件
|
||||
├── content/
|
||||
│ ├── docs/ # 用户文档
|
||||
│ └── blog/ # 开发者博客
|
||||
└── lib/ # 内容源、站点信息与布局配置
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
先安装依赖:
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
从仓库根目录启动:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
浏览器打开 <http://localhost:3000>。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `content/docs` 中维护面向使用者的 `.mdx` 文档。
|
||||
- 在 `content/docs/meta.json` 中维护文档侧边栏标题与页面顺序。
|
||||
- 在 `content/blog` 中维护开发者文章,文件名以 `YYYY-MM-DD-` 开头用于排序和展示日期。
|
||||
- 在 `lib/shared.ts` 中维护站点名称、仓库和发布地址。
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs run check
|
||||
```
|
||||
|
||||
生产构建输出由 Next.js 管理。部署环境可通过 `NEXT_PUBLIC_SITE_URL` 设置站点公开地址;未设置时使用 `https://docs.leokun.cn`。
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import { blogSource, formatBlogDate } from '@/lib/blog';
|
||||
|
||||
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
|
||||
const { slug } = await props.params;
|
||||
const page = blogSource.getPage([slug]);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
|
||||
<article>
|
||||
<Link
|
||||
href="/blog"
|
||||
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回开发者博客
|
||||
</Link>
|
||||
|
||||
<header className="border-b pb-8">
|
||||
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(page.path)}</time>
|
||||
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
|
||||
<p className="mt-5 text-sm font-medium">cursor-byok 开发团队</p>
|
||||
</header>
|
||||
|
||||
<div className="prose mt-10 min-w-0">
|
||||
<InlineTOC items={page.data.toc} />
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: createRelativeLink(blogSource, page),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return blogSource.getPages().map((page) => ({ slug: page.slugs[0] }));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {
|
||||
const { slug } = await props.params;
|
||||
const page = blogSource.getPage([slug]);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '开发者博客',
|
||||
description: 'cursor-byok 的架构决策、协议实现与开发进展。',
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
const posts = sortBlogPages(blogSource.getPages());
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
|
||||
<div className="max-w-2xl">
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight">开发者博客</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
|
||||
记录 cursor-byok 的架构决策、协议实现和开发进展。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
|
||||
<div>
|
||||
<h2 className="font-semibold">{post.data.title}</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
|
||||
{post.data.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Download,
|
||||
Settings2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { DesktopDemo } from '@/components/hero/DesktopDemo';
|
||||
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
|
||||
import { appDescription, releaseUrl } from '@/lib/shared';
|
||||
|
||||
const docs = [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: '快速开始',
|
||||
description: '完成安装、初始化和第一次模型调用。',
|
||||
href: '/docs',
|
||||
},
|
||||
{
|
||||
icon: Settings2,
|
||||
title: '模型配置',
|
||||
description: '配置协议、服务地址、凭据和生成参数。',
|
||||
href: '/docs/model-configuration',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: '故障排查',
|
||||
description: '解决证书、连接和模型测试问题。',
|
||||
href: '/docs/troubleshooting',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const posts = sortBlogPages(blogSource.getPages()).slice(0, 3);
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col">
|
||||
<section className="border-b px-4 pb-16 pt-20 sm:px-6 sm:pb-24 sm:pt-28">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">开源 · 本地运行 · 自由接入</p>
|
||||
<h1 className="mt-5 text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
{appDescription}
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
|
||||
在本机运行自己的模型网关,接入 OpenAI、Anthropic 等兼容服务,继续使用 Cursor Agent 的工具调用、Skills 和 MCP。
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-3">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
阅读文档
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href={releaseUrl}
|
||||
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载最新版
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopDemo />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">DOCUMENTATION</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">文档</h2>
|
||||
</div>
|
||||
<Link href="/docs" className="hidden items-center gap-2 text-sm font-medium sm:flex">
|
||||
查看全部
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||
{docs.map(({ icon: Icon, title, description, href }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
<Icon className="mb-5 size-5 text-fd-primary" />
|
||||
<h3 className="flex items-center justify-between font-semibold">
|
||||
{title}
|
||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-6 py-16 sm:py-20">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight">开发者博客</h2>
|
||||
</div>
|
||||
<Link href="/blog" className="flex items-center gap-2 text-sm font-medium">
|
||||
查看全部
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 divide-y border-y">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.url}
|
||||
href={post.url}
|
||||
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
|
||||
>
|
||||
<time className="text-sm text-fd-muted-foreground">{formatBlogDate(post.path)}</time>
|
||||
<div>
|
||||
<h3 className="font-semibold">{post.data.title}</h3>
|
||||
<p className="mt-1 text-sm text-fd-muted-foreground">{post.data.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from 'fumadocs-ui/layouts/docs/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import type { Metadata } from 'next';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { gitConfig } from '@/lib/shared';
|
||||
|
||||
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
// this allows you to link to other pages with relative file paths
|
||||
a: createRelativeLink(source, page),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
images: getPageImageUrl(page).url,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
return (
|
||||
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
html > body[data-scroll-locked] {
|
||||
margin-right: 0 !important;
|
||||
--removed-body-scroll-bar-size: 0px !important;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { i18nProvider } from 'fumadocs-ui/i18n';
|
||||
import type { Metadata } from 'next';
|
||||
import { translations } from '@/lib/layout.shared';
|
||||
import './global.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'cursor-byok 文档',
|
||||
template: '%s | cursor-byok',
|
||||
},
|
||||
description: 'cursor-byok 的安装、模型配置与故障排查指南。',
|
||||
};
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<RootProvider i18n={i18nProvider(translations)}>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
|
||||
const { slug } = await params;
|
||||
const page = source.getPage(slug?.slice(0, -1));
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(await getLLMText(page), {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.getPages().map((page) => ({
|
||||
lang: page.locale,
|
||||
slug: getPageMarkdownUrl(page).segments,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { llms } from 'fumadocs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(llms(source).index());
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getPageImageUrl, source } from '@/lib/source';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { generate as DefaultImage } from 'fumadocs-ui/og';
|
||||
import { appName } from '@/lib/shared';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
|
||||
const { slug } = await params;
|
||||
const page = source.getPage(slug.slice(0, -1));
|
||||
if (!page) notFound();
|
||||
|
||||
return new ImageResponse(
|
||||
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.getPages().map((page) => ({
|
||||
lang: page.locale,
|
||||
slug: getPageImageUrl(page).segments,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.stage {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 64px auto 0;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 960 / 620;
|
||||
overflow: hidden;
|
||||
background: #141414;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 42px 90px -34px rgb(0 0 0 / 50%),
|
||||
0 12px 32px -16px rgb(0 0 0 / 42%);
|
||||
}
|
||||
|
||||
.demo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #141414;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.stage p {
|
||||
margin: 14px 0 0;
|
||||
color: var(--color-fd-muted-foreground);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.stage {
|
||||
margin-top: 42px;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import styles from './DesktopDemo.module.css';
|
||||
|
||||
export function DesktopDemo() {
|
||||
return (
|
||||
<div className={styles.stage}>
|
||||
<div className={styles.viewport}>
|
||||
<iframe
|
||||
title="Cursor BYOK 真实产品界面演示"
|
||||
src="/product-demo/demo/index.html"
|
||||
className={styles.demo}
|
||||
/>
|
||||
</div>
|
||||
<p>真实桌面端组件 · 使用隔离的 Mock 数据,可直接操作菜单、筛选和设置</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
Step,
|
||||
Steps,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
export const useMDXComponents = getMDXComponents;
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: 为什么我们建立新的文档站
|
||||
description: 将用户指南和开发过程放回代码仓库,让文档与产品一起演进。
|
||||
---
|
||||
|
||||
cursor-byok 的功能已经从单一模型转发扩展到多协议模型配置、工具调用、会话观测和跨平台桌面应用。散落在发布说明与讨论区中的信息,已经不足以支持第一次使用产品的人,也不利于开发者理解系统边界。
|
||||
|
||||
## 文档也是产品的一部分
|
||||
|
||||
新的文档站与应用代码放在同一个仓库中:
|
||||
|
||||
```text
|
||||
apps/
|
||||
├── desktop/ # 桌面应用
|
||||
└── docs/ # 文档站
|
||||
├── content/docs/
|
||||
└── content/blog/
|
||||
```
|
||||
|
||||
用户文档负责回答“如何使用”,开发者博客负责记录“为什么这样设计”。两类内容分开维护,但使用同一套构建和审查流程。
|
||||
|
||||
## 为什么选择 Fumadocs
|
||||
|
||||
Fumadocs 提供了文档布局、全文搜索、代码高亮、目录和 MDX 内容层,我们只需要维护产品信息与视觉样式,不必重新实现通用文档能力。
|
||||
|
||||
文档应用保持独立,桌面端不会引入 Next.js 或 Fumadocs 依赖。开发、构建和部署也可以分别进行。
|
||||
|
||||
## 接下来会记录什么
|
||||
|
||||
开发者博客将持续记录:
|
||||
|
||||
- Cursor 协议适配与模型兼容性设计。
|
||||
- Agent 工具调用和多轮会话的实现取舍。
|
||||
- 本地存储、可观测性与性能优化。
|
||||
- 桌面端跨平台开发和发布过程。
|
||||
|
||||
这些文章以当前代码为准,不为已经删除的旧实现保留兼容说明。
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 快速开始
|
||||
description: 安装 cursor-byok,并让 Cursor 使用你自己的模型 API。
|
||||
icon: Rocket
|
||||
---
|
||||
|
||||
cursor-byok 是运行在本机的 Cursor 模型网关。它接收 Cursor Agent 请求,将请求转换后发送到你配置的 OpenAI 或 Anthropic 兼容服务。
|
||||
|
||||
<Callout type="warn" title="使用前须知">
|
||||
cursor-byok 是独立开源项目,与 Cursor 及其开发者没有关联。软件本身免费,但模型服务商可能按用量收费。
|
||||
</Callout>
|
||||
|
||||
## 三步开始使用
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### 下载并启动
|
||||
|
||||
从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载与你的操作系统对应的最新版本,然后启动 cursor-byok。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 初始化并配置模型
|
||||
|
||||
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 在 Cursor 中使用
|
||||
|
||||
保持 cursor-byok 运行,打开 Cursor,在模型列表中选择刚刚配置的模型,然后开始使用 Agent。
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 接下来
|
||||
|
||||
<Cards>
|
||||
<Card title="安装指南" description="下载、初始化和首次运行。" href="/docs/installation" />
|
||||
<Card title="模型配置" description="选择协议并填写上游模型参数。" href="/docs/model-configuration" />
|
||||
<Card title="故障排查" description="处理证书、连接与模型测试问题。" href="/docs/troubleshooting" />
|
||||
<Card title="查看源码" description="了解实现或参与项目开发。" href="https://github.com/leookun/cursor-byok" external />
|
||||
</Cards>
|
||||
|
||||
## 数据如何流转
|
||||
|
||||
```text
|
||||
Cursor 客户端
|
||||
│ Agent 请求与工具结果
|
||||
▼
|
||||
cursor-byok 本地服务
|
||||
│ OpenAI / Anthropic 兼容请求
|
||||
▼
|
||||
你配置的模型 API
|
||||
```
|
||||
|
||||
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商。
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 安装指南
|
||||
description: 下载 cursor-byok,完成本地初始化并验证运行状态。
|
||||
icon: Download
|
||||
---
|
||||
|
||||
## 下载应用
|
||||
|
||||
前往 [最新版本页面](https://github.com/leookun/cursor-byok/releases/latest),下载适用于 macOS、Windows 或 Linux 的安装包。
|
||||
|
||||
<Callout type="info">
|
||||
优先使用最新正式版本。发行页面会列出该版本包含的安装包和更新说明。
|
||||
</Callout>
|
||||
|
||||
## 首次启动
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### 打开 Cursor 配置
|
||||
|
||||
启动应用后进入 **Cursor 配置**。如果本地 CA 尚未初始化,页面会显示初始化入口。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 初始化本地 CA
|
||||
|
||||
点击 **初始化 CA**。本地 CA 用于在你的设备上解析 Cursor 发出的 HTTPS 请求,其文件只保存在本机。
|
||||
|
||||
系统要求授权时,按照应用显示的说明在终端中完成信任操作,然后返回应用点击 **我已初始化,刷新**。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 添加第一个模型
|
||||
|
||||
点击 **添加模型**,选择 OpenAI 或 Anthropic 类型,填写上游服务参数并保存。详细字段说明见[模型配置](./model-configuration.mdx)。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 运行连通性测试
|
||||
|
||||
点击模型的 **测试**。测试成功后,该模型即可出现在 Cursor 的模型列表中。
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 验证安装
|
||||
|
||||
完成配置后,请确认:
|
||||
|
||||
- Cursor 配置页面不再显示 CA 初始化提示。
|
||||
- 至少一个模型的连通性测试成功。
|
||||
- cursor-byok 保持运行。
|
||||
- Cursor 模型列表中能看到配置的显示名称。
|
||||
|
||||
如果其中任何一步失败,请前往[故障排查](./troubleshooting.mdx)。
|
||||
|
||||
## 更新
|
||||
|
||||
在应用设置中检查新版本,或直接访问 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest)。更新前无需删除现有模型配置。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "使用指南",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "troubleshooting"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 模型配置
|
||||
description: 配置模型协议、服务地址、凭据与生成参数。
|
||||
icon: Settings2
|
||||
---
|
||||
|
||||
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
|
||||
|
||||
## 必填字段
|
||||
|
||||
### 模型类型
|
||||
|
||||
选择上游接口格式:
|
||||
|
||||
- **OpenAI**:支持 Responses API 和 Chat Completions API。
|
||||
- **Anthropic**:支持 Messages API 兼容服务。
|
||||
|
||||
### 请求协议
|
||||
|
||||
OpenAI 类型需要继续选择 **Responses API** 或 **Chat Completions API**。该选项决定请求和响应格式,不会单独改变你填写的服务地址。
|
||||
|
||||
### 服务器地址
|
||||
|
||||
可以填写服务商的基础地址,也可以选择使用完整请求 URL:
|
||||
|
||||
- 使用基础地址时,cursor-byok 会根据协议追加标准端点路径。
|
||||
- 使用完整请求 URL 时,cursor-byok 会原样使用该地址。
|
||||
|
||||
优先使用界面中的常用服务商预设,减少协议与端点不匹配的情况。
|
||||
|
||||
### API Key
|
||||
|
||||
填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
|
||||
|
||||
### 模型名称
|
||||
|
||||
填写服务商接口接受的模型标识。你也可以点击 **获取模型** 读取接口返回的模型列表。
|
||||
|
||||
## 展示信息
|
||||
|
||||
- **显示名称**:Cursor 模型列表中看到的名称,不会改变发送给上游的模型标识。
|
||||
- **备注**:显示在 Cursor 的模型说明中。
|
||||
|
||||
## 可选参数
|
||||
|
||||
按模型能力填写以下字段;留空时使用应用或上游的默认值:
|
||||
|
||||
- 上下文窗口 Token
|
||||
- 最大输出 Token
|
||||
- 推理强度或思考强度
|
||||
- 自定义 Headers
|
||||
- OpenAI 或 Anthropic 额外参数
|
||||
|
||||
<Callout type="warn" title="额外参数格式">
|
||||
自定义 Headers 和额外参数必须是 JSON 对象。额外参数会直接影响上游请求,只添加服务商明确支持的字段。
|
||||
</Callout>
|
||||
|
||||
## 测试配置
|
||||
|
||||
保存后运行 **测试**。结果会显示首字延迟、生成速度、总耗时和模型输出,便于确认:
|
||||
|
||||
1. 地址和协议是否匹配。
|
||||
2. API Key 是否有效。
|
||||
3. 模型标识是否存在且可访问。
|
||||
4. 上游是否能正常返回流式内容。
|
||||
|
||||
测试通过后再前往 Cursor 使用该模型。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 故障排查
|
||||
description: 处理本地 CA、管理服务、模型连接和 Cursor 模型列表问题。
|
||||
icon: Wrench
|
||||
---
|
||||
|
||||
## 需要先初始化本地 CA
|
||||
|
||||
进入 **Cursor 配置**,点击 **初始化 CA**。本地 CA 只保存在本机,用于解析 Cursor 发出的 HTTPS 请求。
|
||||
|
||||
## 需要在系统中信任本地 CA
|
||||
|
||||
点击 **打开终端安装 CA**,按照终端提示完成系统授权。完成后返回应用,点击 **我已初始化,刷新**。
|
||||
|
||||
如果状态没有更新,请完全退出后重新启动 cursor-byok,再次打开 Cursor 配置页面。
|
||||
|
||||
## 无法连接本地管理服务
|
||||
|
||||
1. 完全退出并重新启动 cursor-byok。
|
||||
2. 检查是否有安全软件拦截本地回环连接。
|
||||
3. 如果修改过管理服务端口,将端口恢复为 `0`,让应用在启动时自动选择可用端口。
|
||||
4. 再次启动应用并刷新页面。
|
||||
|
||||
## 模型连通性测试失败
|
||||
|
||||
按测试错误逐项检查:
|
||||
|
||||
- **认证错误**:确认 API Key 有效,且账户有权访问目标模型。
|
||||
- **未找到接口**:确认模型类型、请求协议与服务器地址匹配。
|
||||
- **模型不存在**:使用 **获取模型** 检查上游返回的模型标识。
|
||||
- **参数错误**:暂时关闭自定义 Headers 和额外参数,再重新测试。
|
||||
- **连接超时**:检查网络、系统代理和上游服务状态。
|
||||
|
||||
## Cursor 中看不到模型
|
||||
|
||||
确认以下条件全部满足:
|
||||
|
||||
1. 本地 CA 状态正常。
|
||||
2. 已保存至少一个模型配置。
|
||||
3. 模型连通性测试成功。
|
||||
4. cursor-byok 正在运行。
|
||||
5. 重启 Cursor 后重新打开模型列表。
|
||||
|
||||
## 请求失败但测试成功
|
||||
|
||||
模型测试只验证基础连接。Agent 请求还会包含更长上下文、工具定义和流式响应。请检查:
|
||||
|
||||
- 上游模型是否支持工具调用。
|
||||
- 上下文窗口和最大输出 Token 是否符合模型限制。
|
||||
- 自定义参数是否与实际协议兼容。
|
||||
- 调用详情中的上游状态码和响应内容。
|
||||
|
||||
## 继续反馈
|
||||
|
||||
如果问题仍然存在,请在 [GitHub Issues](https://github.com/leookun/cursor-byok/issues) 提交问题,并附上:
|
||||
|
||||
- 操作系统与 cursor-byok 版本。
|
||||
- 选择的模型类型和请求协议。
|
||||
- 已脱敏的服务地址与错误信息。
|
||||
- 复现步骤。
|
||||
|
||||
请勿公开 API Key 或其他凭据。
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
'.source/**',
|
||||
'public/product-demo/**',
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { defineCollections } from 'fumadocs-mdx/macro';
|
||||
|
||||
const blog = defineCollections({
|
||||
type: 'doc',
|
||||
dir: 'content/blog',
|
||||
schema: pageSchema,
|
||||
});
|
||||
|
||||
export const blogSource = loader({
|
||||
baseUrl: '/blog',
|
||||
source: blog.toFumadocsSource(),
|
||||
});
|
||||
|
||||
export function getBlogDate(path: string): Date {
|
||||
const fileName = path.split('/').at(-1) ?? '';
|
||||
const match = /^(\d{4}-\d{2}-\d{2})/.exec(fileName);
|
||||
return match ? new Date(`${match[1]}T00:00:00Z`) : new Date(0);
|
||||
}
|
||||
|
||||
export function formatBlogDate(path: string): string {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(getBlogDate(path));
|
||||
}
|
||||
|
||||
export function sortBlogPages<T extends { path: string }>(pages: T[]): T[] {
|
||||
return [...pages].sort((left, right) => {
|
||||
return getBlogDate(right.path).getTime() - getBlogDate(left.path).getTime();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cn } from 'cnfast';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { zhCN } from '@fumadocs/language/zh-cn';
|
||||
import { defineTranslations } from 'fumadocs-core/i18n';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { uiTranslations } from 'fumadocs-ui/i18n';
|
||||
import { appName, gitConfig, releaseUrl } from './shared';
|
||||
|
||||
export const translations = defineTranslations().extend(uiTranslations()).preset(zhCN());
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: appName,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: '文档',
|
||||
url: '/docs',
|
||||
},
|
||||
{
|
||||
text: '开发者博客',
|
||||
url: '/blog',
|
||||
},
|
||||
{
|
||||
text: '下载',
|
||||
url: releaseUrl,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const appName = 'cursor-byok';
|
||||
export const appDescription = 'Cursor 服务端的开源替代';
|
||||
export const docsRoute = '/docs';
|
||||
export const docsImageRoute = '/og/docs';
|
||||
export const docsContentRoute = '/llms.mdx/docs';
|
||||
export const repositoryUrl = 'https://github.com/leookun/cursor-byok';
|
||||
export const releaseUrl = `${repositoryUrl}/releases/latest`;
|
||||
|
||||
export const gitConfig = {
|
||||
user: 'leookun',
|
||||
repo: 'cursor-byok',
|
||||
branch: 'main',
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
|
||||
import { defineDocs } from 'fumadocs-mdx/macro';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
|
||||
const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
docs: {
|
||||
schema: pageSchema,
|
||||
postprocess: {
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: docsRoute,
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
|
||||
const segments = [...page.slugs, 'image.png'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: '/' + [page.locale, ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
|
||||
const segments = [...page.slugs, 'content.md'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: '/' + [page.locale, ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: (typeof source)['$inferPage']) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
Generated
+10518
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "cursor-byok-docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:product-demo": "node scripts/build-product-demo.mjs",
|
||||
"prebuild": "npm run build:product-demo",
|
||||
"build": "next build",
|
||||
"predev": "npm run build:product-demo",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"types:check": "next typegen && tsc --noEmit",
|
||||
"lint": "eslint",
|
||||
"check": "npm run types:check && npm run lint && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumadocs/language": "^0.2.4",
|
||||
"cnfast": "^0.1.0",
|
||||
"fumadocs-core": "16.15.2",
|
||||
"fumadocs-mdx": "15.3.1",
|
||||
"fumadocs-ui": "npm:@fumadocs/base-ui@16.15.2",
|
||||
"lucide-react": "^1.34.0",
|
||||
"next": "16.3.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.3.2",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
|
||||
import { docsContentRoute, docsRoute } from '@/lib/shared';
|
||||
|
||||
const { rewrite: rewriteDocs } = rewritePath(
|
||||
`${docsRoute}{/*path}`,
|
||||
`${docsContentRoute}{/*path}/content.md`,
|
||||
);
|
||||
const { rewrite: rewriteSuffix } = rewritePath(
|
||||
`${docsRoute}{/*path}.md`,
|
||||
`${docsContentRoute}{/*path}/content.md`,
|
||||
);
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const result = rewriteSuffix(request.nextUrl.pathname);
|
||||
if (result) {
|
||||
return NextResponse.rewrite(new URL(result, request.nextUrl));
|
||||
}
|
||||
|
||||
if (isMarkdownPreferred(request)) {
|
||||
const result = rewriteDocs(request.nextUrl.pathname);
|
||||
|
||||
if (result) {
|
||||
return NextResponse.rewrite(new URL(result, request.nextUrl), {
|
||||
// this URL has two representations, selected by `Accept`
|
||||
headers: { Vary: 'Accept' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const docsRoot = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
|
||||
const desktopRoot = path.resolve(docsRoot, '../desktop');
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const vite = path.join(desktopRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite');
|
||||
|
||||
if (!existsSync(vite)) run(['ci'], desktopRoot);
|
||||
run(['run', 'build:demo'], desktopRoot);
|
||||
|
||||
function run(args, cwd) {
|
||||
const result = spawnSync(npm, args, { cwd, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"tools": [
|
||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||
"EditNotebook", "TodoWrite", "StrReplace", "Write", "Read", "ReadLints",
|
||||
"Glob", "AskQuestion", "Task", "AwaitShell", "GetMcpTools",
|
||||
"Glob", "AskQuestion", "Task", "GetMcpTools",
|
||||
"FetchMcpResource", "SwitchMode", "CallMcpTool", "SembleSearch",
|
||||
"SembleFindRelated"
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"tools": [
|
||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||
"ReadLints", "EditNotebook", "TodoWrite", "StrReplace", "Write", "Read",
|
||||
"Glob", "AwaitShell", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
||||
"Glob", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
||||
"UpdateCurrentStep", "CallMcpTool", "SembleSearch", "SembleFindRelated"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-1
@@ -35,7 +35,10 @@ impl App {
|
||||
.set_port(store.port_settings().await?.service_port);
|
||||
}
|
||||
let assets = PromptAssets::embedded()?;
|
||||
let compiler = PromptCompiler::new(assets);
|
||||
let compiler = PromptCompiler::with_compaction_prompt_path(
|
||||
assets,
|
||||
crate::config::compaction_prompt_path()?,
|
||||
);
|
||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||
store.clone(),
|
||||
config.provider_request_timeout,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct MessageInsertion {
|
||||
#[derive(Debug)]
|
||||
pub enum ClientCommand {
|
||||
ToolResult(ToolResult),
|
||||
RuntimeMessage(CanonicalMessage),
|
||||
InterruptWithMessage(CanonicalMessage),
|
||||
RuntimeEvent(RuntimeEvent),
|
||||
InsertMessages(MessageInsertion),
|
||||
ClientClosed { error: String },
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::run::RunOutcome;
|
||||
pub enum CommitCause {
|
||||
InitialMessages,
|
||||
ToolRoundStarted(ToolRoundId),
|
||||
ToolResult { call_id: String },
|
||||
ToolResult { call_id: String, interrupted: bool },
|
||||
FinalTurn,
|
||||
Compaction { summary: String },
|
||||
RuntimeEvent { event_id: String },
|
||||
|
||||
+39
-2
@@ -9,6 +9,8 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3";
|
||||
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";
|
||||
const COMPACTION_PROMPT_PATH: &str = "prompts/compaction.md";
|
||||
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000);
|
||||
|
||||
pub fn managed_data_dir() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir()
|
||||
@@ -28,6 +30,26 @@ pub fn v0049_config_path() -> Result<PathBuf> {
|
||||
.join(V0049_CONFIG_FILE_NAME))
|
||||
}
|
||||
|
||||
pub fn compaction_prompt_path() -> Result<PathBuf> {
|
||||
Ok(managed_data_dir()?.join(COMPACTION_PROMPT_PATH))
|
||||
}
|
||||
|
||||
pub fn compaction_prompt_override() -> Result<Option<String>> {
|
||||
compaction_prompt_override_at(&compaction_prompt_path()?)
|
||||
}
|
||||
|
||||
pub(crate) fn compaction_prompt_override_at(path: &std::path::Path) -> Result<Option<String>> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(prompt) if prompt.trim().is_empty() => Ok(None),
|
||||
Ok(prompt) => Ok(Some(prompt)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(Error::Config(format!(
|
||||
"cannot read compaction prompt at {}: {error}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderKind {
|
||||
OpenAiChat,
|
||||
@@ -70,7 +92,7 @@ impl Config {
|
||||
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
||||
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
||||
})?),
|
||||
Err(env::VarError::NotPresent) => Duration::from_secs(300),
|
||||
Err(env::VarError::NotPresent) => DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
Err(error) => {
|
||||
return Err(Error::Config(format!(
|
||||
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
||||
@@ -111,7 +133,7 @@ impl Config {
|
||||
.parse()
|
||||
.expect("desktop listen address is static"),
|
||||
database_url: default_database_url()?,
|
||||
provider_request_timeout: Duration::from_secs(300),
|
||||
provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
console: None,
|
||||
use_persisted_ports: true,
|
||||
})
|
||||
@@ -156,6 +178,21 @@ fn database_url_for_dir(data_dir: &std::path::Path) -> Result<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compaction_prompt_override_is_optional_and_reloaded() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("compaction.md");
|
||||
|
||||
assert_eq!(compaction_prompt_override_at(&path).unwrap(), None);
|
||||
fs::write(&path, " \n").unwrap();
|
||||
assert_eq!(compaction_prompt_override_at(&path).unwrap(), None);
|
||||
fs::write(&path, "custom prompt").unwrap();
|
||||
assert_eq!(
|
||||
compaction_prompt_override_at(&path).unwrap().as_deref(),
|
||||
Some("custom prompt")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_database_supports_home_paths_with_spaces() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
+193
-12
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
@@ -281,6 +285,10 @@ impl CursorActor {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tool_runtime.is_interrupted(throw.id).await {
|
||||
tool_runtime.discard_exec(throw.id).await;
|
||||
continue;
|
||||
}
|
||||
match tool_runtime.take_exec(throw.id).await {
|
||||
Some(pending) => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
|
||||
@@ -152,6 +152,19 @@ pub fn context_injection_queued(injection_id: String) -> pb::AgentServerMessage
|
||||
))
|
||||
}
|
||||
|
||||
pub fn context_injection_rejected(injection_id: String, reason: String) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::ContextInjectionState(
|
||||
pb::ContextInjectionStateUpdate {
|
||||
injection_id,
|
||||
state: Some(pb::ContextInjectionState {
|
||||
state: Some(pb::context_injection_state::State::Rejected(
|
||||
pb::ContextInjectionRejected { reason },
|
||||
)),
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn context_injection_delivered(
|
||||
injection_id: String,
|
||||
delivery_batch_id: String,
|
||||
|
||||
@@ -188,7 +188,6 @@ pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||
"updatecurrentstep" => {
|
||||
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
||||
}
|
||||
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
|
||||
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
||||
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
||||
};
|
||||
@@ -457,17 +456,6 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
chars: string("chars"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
|
||||
tool.args = Some(pb::AwaitArgs {
|
||||
task_id: string("shell_id"),
|
||||
block_until_ms: call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
regex: optional("pattern"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
||||
tool.args = Some(pb::GetMcpToolsArgs {
|
||||
server: optional("server"),
|
||||
|
||||
@@ -158,7 +158,6 @@ fn tool_identifier(name: &str, dynamic_tools: &HashSet<String>) -> String {
|
||||
return name.into();
|
||||
}
|
||||
match name {
|
||||
"AwaitShell" => "AWAIT".into(),
|
||||
"CallMcpTool" | "SembleSearch" | "SembleFindRelated" => "MCP".into(),
|
||||
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
||||
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
model::{ModelSpec, PromptSpec, ToolDefinition},
|
||||
@@ -10,11 +10,22 @@ use super::{assets::runtime_expression, Mode, PromptAssets};
|
||||
#[derive(Clone)]
|
||||
pub struct PromptCompiler {
|
||||
assets: PromptAssets,
|
||||
compaction_prompt_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PromptCompiler {
|
||||
pub fn new(assets: PromptAssets) -> Self {
|
||||
Self { assets }
|
||||
Self {
|
||||
assets,
|
||||
compaction_prompt_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_compaction_prompt_path(assets: PromptAssets, path: PathBuf) -> Self {
|
||||
Self {
|
||||
assets,
|
||||
compaction_prompt_path: Some(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_message(&self, mode: Mode, values: &BTreeMap<&str, String>) -> Result<String> {
|
||||
@@ -39,12 +50,13 @@ impl PromptCompiler {
|
||||
.display_name
|
||||
.as_deref()
|
||||
.unwrap_or(model.model_id.as_str());
|
||||
let prompt = match (mode, &self.compaction_prompt_path) {
|
||||
(Mode::Compaction, Some(path)) => crate::config::compaction_prompt_override_at(path)?
|
||||
.unwrap_or_else(|| self.assets.mode(mode).prompt.clone()),
|
||||
_ => self.assets.mode(mode).prompt.clone(),
|
||||
};
|
||||
Ok(PromptSpec {
|
||||
instructions: self
|
||||
.assets
|
||||
.mode(mode)
|
||||
.prompt
|
||||
.replace("{{FAKE_MODEL_NAME}}", fake_model_name),
|
||||
instructions: prompt.replace("{{FAKE_MODEL_NAME}}", fake_model_name),
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -505,6 +505,11 @@ fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Va
|
||||
if !object_only_union {
|
||||
return Err(invalid_mcp_parameters(tool_name));
|
||||
}
|
||||
// OpenAI-compatible function schemas (and the corresponding schema
|
||||
// validators used by other providers) require the root schema to declare
|
||||
// an object type. Cursor's app-control MCP sometimes sends an object-only
|
||||
// `anyOf`/`oneOf` schema without that root annotation. Preserve the union
|
||||
// while adding the annotation to the model-facing copy.
|
||||
schema.insert("type".into(), Value::String("object".into()));
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ impl CursorSession {
|
||||
let mut response_text = String::new();
|
||||
let mut response_thinking = String::new();
|
||||
let mut active_round = None::<ToolRoundId>;
|
||||
let mut active_tool_calls = HashSet::<String>::new();
|
||||
let mut interrupted_rounds = HashSet::<ToolRoundId>::new();
|
||||
let mut interrupted_tool_calls = HashSet::<String>::new();
|
||||
let mut final_checkpoint = None::<FinalCheckpoints>;
|
||||
let mut compaction_checkpoint = None::<pb::ConversationStateStructure>;
|
||||
let mut turn_usage = None::<Usage>;
|
||||
@@ -130,13 +133,16 @@ impl CursorSession {
|
||||
let mut presentation = Presentation::default();
|
||||
|
||||
loop {
|
||||
let input = if let Some(completion) = ready.pop_front() {
|
||||
let input = if let Ok(action) = self.runtime_actions.try_recv() {
|
||||
Input::RuntimeAction(Some(Box::new(action)))
|
||||
} else if let Some(completion) = ready.pop_front() {
|
||||
Input::Completion(completion)
|
||||
} else {
|
||||
tokio::select! {
|
||||
biased;
|
||||
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
|
||||
event = self.core.events.recv() => Input::Event(event),
|
||||
completion = self.results.recv() => Input::CompletionResult(completion),
|
||||
action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)),
|
||||
failure = worker.failures.recv(), if checkpoint_worker_open => Input::CheckpointFailure(failure),
|
||||
}
|
||||
};
|
||||
@@ -147,15 +153,16 @@ impl CursorSession {
|
||||
}
|
||||
Input::Completion(completion) => {
|
||||
if let Some(completion) = self
|
||||
.forward_completion(completion, &mut completions)
|
||||
.forward_completion(completion, &mut completions, &interrupted_tool_calls)
|
||||
.await?
|
||||
{
|
||||
ready.push_back(completion);
|
||||
}
|
||||
}
|
||||
Input::CompletionResult(Some(result)) => {
|
||||
if let Some(completion) =
|
||||
self.forward_completion(result?, &mut completions).await?
|
||||
if let Some(completion) = self
|
||||
.forward_completion(result?, &mut completions, &interrupted_tool_calls)
|
||||
.await?
|
||||
{
|
||||
ready.push_back(completion);
|
||||
}
|
||||
@@ -164,7 +171,15 @@ impl CursorSession {
|
||||
return Err(Error::Protocol("tool result channel closed".into()));
|
||||
}
|
||||
Input::RuntimeAction(Some(action)) => {
|
||||
self.forward_injection(*action).await?;
|
||||
self.forward_injection(
|
||||
*action,
|
||||
active_round.as_ref(),
|
||||
&active_tool_calls,
|
||||
&completions,
|
||||
&mut interrupted_rounds,
|
||||
&mut interrupted_tool_calls,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Input::RuntimeAction(None) => {
|
||||
return Err(Error::Protocol("runtime action channel closed".into()));
|
||||
@@ -283,7 +298,23 @@ impl CursorSession {
|
||||
round_id,
|
||||
calls: round_calls,
|
||||
} => {
|
||||
active_round = Some(round_id);
|
||||
active_round = Some(round_id.clone());
|
||||
active_tool_calls = round_calls
|
||||
.iter()
|
||||
.map(|call| call.call_id.clone())
|
||||
.collect();
|
||||
// Runtime actions are deliberately prioritized over core events. An
|
||||
// injection can therefore be observed before the already-queued
|
||||
// ToolRoundStarted event reaches this session. In that case the
|
||||
// accepted injection is still pending delivery and this round must be
|
||||
// detached without starting any root tools.
|
||||
if interrupted_rounds.contains(&round_id)
|
||||
|| !self.pending_injections.is_empty()
|
||||
{
|
||||
interrupted_rounds.insert(round_id.clone());
|
||||
interrupted_tool_calls.extend(active_tool_calls.iter().cloned());
|
||||
continue;
|
||||
}
|
||||
for dispatched in self
|
||||
.tools
|
||||
.start_batch(
|
||||
@@ -348,12 +379,11 @@ impl CursorSession {
|
||||
active_round = Some(round_id.clone());
|
||||
}
|
||||
let mut tool_round_settled = false;
|
||||
if let CommitCause::ToolResult { call_id } = &state.cause {
|
||||
let completion = completions.remove(call_id).ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"core committed a tool result without typed Cursor state: {call_id}"
|
||||
))
|
||||
})?;
|
||||
if let CommitCause::ToolResult {
|
||||
call_id,
|
||||
interrupted,
|
||||
} = &state.cause
|
||||
{
|
||||
let snapshot = self
|
||||
.store
|
||||
.tool_round(active_round.as_ref().ok_or_else(|| {
|
||||
@@ -372,9 +402,16 @@ impl CursorSession {
|
||||
"committed call is absent from tool round: {call_id}"
|
||||
))
|
||||
})?;
|
||||
self.handle
|
||||
.emit(&interaction::tool_completed(call, &completion))?;
|
||||
presentation.tool_completed(&completion);
|
||||
if !interrupted {
|
||||
let completion = completions.remove(call_id).ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"core committed a tool result without typed Cursor state: {call_id}"
|
||||
))
|
||||
})?;
|
||||
self.handle
|
||||
.emit(&interaction::tool_completed(call, &completion))?;
|
||||
presentation.tool_completed(&completion);
|
||||
}
|
||||
completed.insert(call_id.clone());
|
||||
tool_round_settled = snapshot.status == ToolRoundStatus::Settled;
|
||||
}
|
||||
@@ -490,7 +527,10 @@ impl CursorSession {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
active_round = None;
|
||||
if let Some(round_id) = active_round.take() {
|
||||
interrupted_rounds.remove(&round_id);
|
||||
}
|
||||
active_tool_calls.clear();
|
||||
self.tool_runtime.clear_completed().await;
|
||||
} else if !matches!(&state.cause, CommitCause::ToolResult { .. })
|
||||
&& active_round.is_some()
|
||||
@@ -603,7 +643,11 @@ impl CursorSession {
|
||||
&self,
|
||||
mut completion: ToolCompletion,
|
||||
completions: &mut HashMap<String, ToolCompletion>,
|
||||
interrupted_tool_calls: &HashSet<String>,
|
||||
) -> Result<Option<ToolCompletion>> {
|
||||
if interrupted_tool_calls.contains(&completion.result().call_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(image) = completion.take_read_image() {
|
||||
let blob_id = self.store.put_blob(&image.data, &[]).await?;
|
||||
completion.persist_read_image(&blob_id, &image)?;
|
||||
@@ -635,19 +679,33 @@ impl CursorSession {
|
||||
Ok(dispatched.completion)
|
||||
}
|
||||
|
||||
async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> {
|
||||
async fn forward_injection(
|
||||
&mut self,
|
||||
action: pb::InjectContextAction,
|
||||
active_round: Option<&ToolRoundId>,
|
||||
active_tool_calls: &HashSet<String>,
|
||||
completions: &HashMap<String, ToolCompletion>,
|
||||
interrupted_rounds: &mut HashSet<ToolRoundId>,
|
||||
interrupted_tool_calls: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
if action.injection_id.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"InjectContextAction has no injection_id".into(),
|
||||
));
|
||||
}
|
||||
if self.injection_ids.contains(&action.injection_id) {
|
||||
return Ok(());
|
||||
}
|
||||
if action.expected_run_id != self.context.request_id {
|
||||
return Err(Error::Protocol(format!(
|
||||
let reason = format!(
|
||||
"InjectContextAction expected run {}, active run is {}",
|
||||
action.expected_run_id, self.context.request_id
|
||||
)));
|
||||
}
|
||||
if self.injection_ids.contains(&action.injection_id) {
|
||||
);
|
||||
self.handle.emit(&interaction::context_injection_rejected(
|
||||
action.injection_id.clone(),
|
||||
reason,
|
||||
))?;
|
||||
self.injection_ids.insert(action.injection_id);
|
||||
return Ok(());
|
||||
}
|
||||
let user_message = match action.payload.as_ref() {
|
||||
@@ -675,25 +733,31 @@ impl CursorSession {
|
||||
);
|
||||
self.handle
|
||||
.emit(&interaction::context_injection_queued(injection_id.clone()))?;
|
||||
interrupted_tool_calls.extend(
|
||||
active_tool_calls
|
||||
.iter()
|
||||
.filter(|call_id| !completions.contains_key(*call_id))
|
||||
.cloned(),
|
||||
);
|
||||
if let Some(round_id) = active_round {
|
||||
interrupted_rounds.insert(round_id.clone());
|
||||
}
|
||||
self.interrupt_execs().await;
|
||||
if self
|
||||
.core
|
||||
.commands
|
||||
.send(ClientCommand::RuntimeMessage(message))
|
||||
.send(ClientCommand::InterruptWithMessage(message))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
self.pending_injections.remove(&injection_id);
|
||||
return Err(Error::RunNotFound(self.context.request_id.clone()));
|
||||
}
|
||||
self.interrupt_execs().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn interrupt_execs(&self) {
|
||||
// Keep runtime entries until Cursor returns the aborted result. The core tool
|
||||
// round needs that terminal result before it can append the injected context
|
||||
// after the complete assistant/tool pair and continue the same Run.
|
||||
for id in self.tool_runtime.running_exec_ids().await {
|
||||
for id in self.tools.interrupt_for_message().await {
|
||||
let _ = self.handle.emit(&codec::abort(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,5 @@ mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::{abort, mcp_request, mcp_state_request, request};
|
||||
pub(crate) use request::{
|
||||
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
|
||||
};
|
||||
pub(crate) use request::{edit_read_request, json_object_to_prost, mcp_meta_request};
|
||||
pub use response::{client_event, stream_closed, ClientExecEvent};
|
||||
|
||||
@@ -191,32 +191,6 @@ pub(crate) fn edit_read_request(id: u32, call: &ToolCall) -> Result<pb::AgentSer
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn await_read_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
let task_id = call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::ReadArgs(pb::ReadArgs {
|
||||
path: format!(
|
||||
"{}/{}.txt",
|
||||
context.terminals_folder.trim_end_matches('/'),
|
||||
task_id
|
||||
),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(false),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn edit_write_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::request::{await_read_request, edit_write_request};
|
||||
use super::request::edit_write_request;
|
||||
|
||||
pub enum ClientExecEvent {
|
||||
Delta(Box<pb::AgentServerMessage>),
|
||||
@@ -25,6 +25,12 @@ pub async fn client_event(
|
||||
message: &pb::ExecClientMessage,
|
||||
pending: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
if pending.is_interrupted(message.id).await {
|
||||
if message.message.as_ref().is_some_and(is_terminal) {
|
||||
pending.discard_exec(message.id).await;
|
||||
}
|
||||
return Ok(ClientExecEvent::Pending);
|
||||
}
|
||||
let call = match pending.exec_call(message.id).await {
|
||||
Some(call) => call,
|
||||
None if pending.completed_call(message.id).await.is_some() => {
|
||||
@@ -47,7 +53,6 @@ pub async fn client_event(
|
||||
let entry = take(message.id, pending).await?;
|
||||
return match entry.stage {
|
||||
ExecStage::EditRead => advance_edit(entry, wire_result, pending).await,
|
||||
ExecStage::Await(_) => advance_await(entry, wire_result, pending).await,
|
||||
ExecStage::Direct | ExecStage::DynamicMcp(_) | ExecStage::EditWrite(_) => {
|
||||
completed(entry, wire_result.clone())
|
||||
}
|
||||
@@ -131,6 +136,10 @@ pub async fn client_event(
|
||||
}
|
||||
|
||||
pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Option<ToolCompletion>> {
|
||||
if pending.is_interrupted(id).await {
|
||||
pending.discard_exec(id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(entry) = pending.take_exec(id).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -177,79 +186,20 @@ pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Optio
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn advance_await(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
registry: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let read = match result {
|
||||
pb::exec_client_message::Message::ReadResult(result)
|
||||
| pb::exec_client_message::Message::RedactedReadResult(result) => result,
|
||||
_ => return Err(Error::Protocol("AwaitShell expected ReadResult".into())),
|
||||
};
|
||||
let ExecStage::Await(state) = &entry.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
let content = match read.result.as_ref() {
|
||||
Some(pb::read_result::Result::Success(success)) => match success.output.as_ref() {
|
||||
Some(pb::read_success::Output::Content(content)) => content.as_str(),
|
||||
_ => "",
|
||||
},
|
||||
Some(pb::read_result::Result::FileNotFound(_)) => "",
|
||||
Some(pb::read_result::Result::Error(error)) => {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_error(
|
||||
entry,
|
||||
&error.error,
|
||||
)?)))
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let regex_match = state
|
||||
.regex
|
||||
.as_ref()
|
||||
.map(|pattern| regex::Regex::new(pattern))
|
||||
.transpose()
|
||||
.map_err(|error| Error::Protocol(format!("invalid AwaitShell pattern: {error}")))?
|
||||
.and_then(|pattern| {
|
||||
pattern
|
||||
.find(content)
|
||||
.map(|found| found.as_str().to_string())
|
||||
});
|
||||
let exit_code = content.lines().find_map(|line| {
|
||||
line.strip_prefix("exit_code:")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
});
|
||||
if regex_match.is_some() || exit_code.is_some() || std::time::Instant::now() >= state.deadline {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_result(
|
||||
entry,
|
||||
content.len() as u64,
|
||||
regex_match,
|
||||
exit_code,
|
||||
)?)));
|
||||
fn is_terminal(message: &pb::exec_client_message::Message) -> bool {
|
||||
use pb::{exec_client_message::Message, shell_stream::Event};
|
||||
|
||||
match message {
|
||||
Message::ShellStream(stream) => matches!(
|
||||
stream.event.as_ref(),
|
||||
Some(Event::Exit(_))
|
||||
| Some(Event::Backgrounded(_))
|
||||
| Some(Event::Rejected(_))
|
||||
| Some(Event::PermissionDenied(_))
|
||||
| Some(Event::SandboxUnsupported(_))
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
let state = match entry.stage {
|
||||
ExecStage::Await(state) => state,
|
||||
_ => {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result changed execution stage".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let wait = state
|
||||
.deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.min(std::time::Duration::from_secs(1));
|
||||
tokio::time::sleep(wait).await;
|
||||
let call = entry.call.clone();
|
||||
let context = entry.context.clone();
|
||||
let id = registry
|
||||
.reserve_await_again(&call, &context, state, entry.started_at_ms)
|
||||
.await?;
|
||||
Ok(ClientExecEvent::Message(Box::new(await_read_request(
|
||||
id, &call, &context,
|
||||
)?)))
|
||||
}
|
||||
|
||||
async fn advance_edit(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user