Compare commits

..
Author SHA1 Message Date
leookun 59379f1f72 fix: repair stream lifecycle reliability 2026-08-28 13:00:39 +08:00
leookun 370f120b8a docs: update README to reflect recent changes and improve clarity 2026-08-28 12:50:01 +08:00
leookun 1ab86c1851 docs: update image references in README for consistency 2026-08-28 12:44:18 +08:00
leokun d015d1c33b docs: sync README from source repo 2026-08-28 12:36:30 +08:00
leokun 1fcb78ad95 Merge pull request #366 from joel01-dev/fix/stream-reliability-v2 2026-08-28 12:14:10 +08:00
joel01-dev b2610c562e fix: restore upstream openai_chat.rs, add only debug logging 2026-08-28 01:12:31 -03:00
joel01-dev 318545cebd fix: restore upstream dispatch/mod.rs, add only Bash alias 2026-08-28 01:07:41 -03:00
joel01-dev 2369d101ec docs: add PR description 2026-08-28 00:48:43 -03:00
joel01-dev 80604bdf97 fix: prevent silent SSE stream hangs, add Bash alias, debug logging
- Actor: use lifecycle::cancel() instead of handle.cancel() on channel
  close and Abort, ensuring end-stream frame is always emitted
- Lifecycle: use match instead of ? on encode_error_end_stream with
  fallback end-stream frame to prevent silent hangs
- Dispatch: add Bash tool alias for Shell
- Context/Blob sync: increase timeouts from 15s to 60s
- Providers: add detailed debug logging to openai_chat and router
2026-08-28 00:47:53 -03:00
leookun c4031d4074 chore(release): bump desktop to v0.1.5-beta.1 2026-08-28 10:40:26 +08:00
leookun b15e149b9d fix:add anthropic cache block 2026-08-28 10:33:00 +08:00
leookun 15f2280e07 Merge branch 'main' of github.com:leookun/cursor-byok 2026-08-28 10:18:18 +08:00
leookun 71ddf71ec1 feat(api): enhance statistics storage management with scope options
- Introduced a new type `StatisticsStorageScope` to specify the scope for clearing statistics.
- Updated the `clearStatisticsStorage` API method to accept a scope parameter, allowing for selective clearing of detailed records or all statistics.
- Modified the demo API to handle the new scope parameter appropriately.
- Updated the SettingsPage component to include a selection for clearing scope, enhancing user control over statistics management.
- Added new translations for the updated messages related to statistics clearing in both English and Chinese.
2026-08-28 10:18:11 +08:00
leokun cde4d55fd2 Merge pull request #361 from LyricalNanoha/fix/omit-empty-tools-array 2026-08-28 10:11:09 +08:00
leookun 217f984112 feat(docs): enhance documentation and UI consistency
- Updated the index.html to dynamically set the theme color based on user selection, improving user experience during theme switching.
- Modified proxy.ts to include 'icon.png' in the matcher for better asset handling.
- Added a new icon.png file for enhanced branding.
- Improved metadata in layout.tsx and page.tsx files for better clarity and consistency in documentation.
- Updated various documentation files to reflect the new branding of 'Cursor Byok' and ensure consistent terminology throughout.
- Removed outdated blog entries and added new content to better align with the current product direction.
2026-08-28 01:33:51 +08:00
leookun 2b54014cf9 feat(docs): update ESLint configuration to include additional directories
- Added '.open-next/**', '.wrangler/**', and 'cloudflare-env.d.ts' to the ESLint configuration for improved linting coverage of new files and directories.
2026-08-28 00:47:45 +08:00
leookun 6b65a4ae31 feat(docs): reorganize images in README for clarity and consistency
- Moved image assets to a more appropriate section in README.md for better visual flow.
- Removed duplicate image entries to streamline the documentation.
2026-08-28 00:43:40 +08:00
leookun 8ca62e933b feat(docs): update image assets in README for improved visuals
- Replaced existing images in README.md with updated versions for brand, dashboard, and model settings.
- Added new image files: en-brand-1.png, en-home-1.png, and en-model-1.png.
2026-08-28 00:42:52 +08:00
leookun c711690f90 feat(docs): update configuration and improve caching for documentation site
- Added Cloudflare and OpenNext configurations to .gitignore.
- Updated next.config.mjs to disable image optimization for better performance with CDN.
- Modified fetchJson function in github.ts to use 'force-cache' for improved data fetching efficiency.
- Updated multiple image assets for the documentation site.
2026-08-28 00:41:13 +08:00
leookun ed300cc92e feat(docs): add wrangler configuration for cursor-byok documentation site
- Created a new wrangler.jsonc file to define the configuration for the cursor-byok-docs project.
- Set compatibility date and enabled necessary compatibility flags for Node.js API and public fetch.
- Specified asset directory and service bindings for the documentation site.
2026-08-28 00:35:37 +08:00
leookun eca063e424 feat(docs): enhance documentation site with internationalization and layout updates
- Updated the demo API to support locale-based content rendering.
- Refactored the main layout to accommodate language-specific metadata and routing.
- Introduced new components for blog and documentation pages, ensuring proper language handling.
- Added styles for improved UI consistency across different sections.
- Removed deprecated layout files and streamlined the structure for better maintainability.
2026-08-28 00:35:31 +08:00
leokun 6c758f2b44 Merge branch 'main' of github.com:leookun/cursor-byok
# Conflicts:
#	server/src/cursor/tools/dispatch/mod.rs
2026-08-27 21:47:27 +08:00
leokun 9cb16e116b feat(cursor): normalize block_until_ms argument for shell tool calls
- Added a new function to normalize the `block_until_ms` argument for shell tool calls, ensuring it is an integer and within valid range.
- Updated the `start` function to utilize the normalization logic.
- Introduced tests to validate the behavior of the normalization function, including acceptance of integer-valued floats and rejection of invalid values.
2026-08-27 21:45:36 +08:00
leokun c5d578c5b1 feat(docs): add documentation site and demo features
- Introduced a new documentation site for Cursor BYOK using Next.js and Fumadocs.
- Added a product demo page with a corresponding Vite configuration.
- Implemented a demo API to simulate LLM calls and responses.
- Enhanced the Makefile to include new build and development commands for the documentation.
- Updated package.json scripts for building and running the documentation site.
- Created various components and layouts for the documentation structure, including blog and user documentation sections.
- Added styling for the new components and layouts to ensure a cohesive design.
- Included a README and other necessary files for local development and deployment.
2026-08-27 21:45:28 +08:00
leokun 661ce11207 Merge pull request #359 from jiah0231/fix/shell-block-until-ms
修复:兼容 Shell/AwaitShell 的整数值 block_until_ms
2026-08-27 19:57:52 +08:00
leokun 477750860f Merge pull request #363 from Weizhena/fix/ollama-reasoning
fix: accept reasoning field in OpenAI Chat streams( When using Ollama Cloud)
2026-08-27 19:56:57 +08:00
Jilin Chen 02a190489a fix: accept reasoning field in OpenAI Chat streams 2026-08-27 19:23:58 +08:00
leokun ee915ee760 fix: preempt root loop on context injection 2026-08-27 17:01:03 +08:00
leokun aa68205735 fix: extend provider request timeout 2026-08-27 15:54:08 +08:00
nanoha e4515dc4e0 fix: omit tools field from provider requests when empty
vLLM and some OpenAI-compatible APIs reject requests containing an
empty `tools: []` array with a 400 Bad Request. This affects the
model connectivity test which sends no tools. Instead of always
including the tools field, conditionally add it only when tools are
present.
2026-08-27 14:02:16 +08:00
leokun 14d6a501a0 chore: release v0.1.4 2026-08-27 11:30:54 +08:00
leokun 82f3ae8d59 feat(settings): add dock icon visibility setting to desktop settings
- Introduced a new setting `show_dock_icon` in the `DesktopSettings` interface.
- Updated `AppLifecycleSettingsCard` to manage dock icon visibility with a toggle switch.
- Enhanced the API to read and write the new dock icon visibility setting.
- Added corresponding translations for the new setting in both English and Chinese locales.
- Updated tests to ensure the default behavior of showing the dock icon and proper round-trip functionality for settings.
2026-08-27 11:24:04 +08:00
leokun 31dfa8d295 refactor: improve MCP parameter normalization and test clarity
- Changed `normalize_mcp_parameters` to avoid mutating the input parameters.
- Updated the test name to better reflect its purpose of preserving the cursor object union schema.
- Adjusted assertions in the test to compare the entire schema instead of individual fields.
2026-08-27 11:11:13 +08:00
jiah0231 7914f24d83 fix: preserve dynamic MCP argument schemas 2026-08-27 10:31:18 +08:00
jiah0231 b85cb37898 fix: normalize integer-valued shell timeouts 2026-08-27 10:30:42 +08:00
leookun 995a12df44 chore: update dependencies and improve chart components
- Updated `foreign-types` to version 0.5.0 and added new dependencies in `Cargo.lock`.
- Removed unused dependencies `chart.js` and `react-chartjs-2` from `package.json` and `package-lock.json`.
- Enhanced `CacheHitRateChart` to use `EChart` for rendering, improving performance and visual fidelity.
- Adjusted styles for `CacheHitRateChart` to ensure proper layout and responsiveness.
- Refactored API query construction in `api.ts` for better readability.
- Updated `HomeMetrics` to support refresh functionality for dynamic data updates.
2026-08-27 00:34:35 +08:00
leokun 7058d0193d Merge pull request #356 from GGboya/feat/provider-presets
feat(console): dual-protocol provider presets + fix sub-path model di…
2026-08-27 00:12:14 +08:00
leookun eff642fdcf Merge branch 'main' into pr/GGboya/356 2026-08-26 23:28:11 +08:00
leokun 2c95d7f836 fix: finalize draft releases by asset id 2026-08-26 22:44:27 +08:00
ggboysongyuhangandClaude Fable 5 75babb7334 feat(console): dual-protocol provider presets + fix sub-path model discovery
Console — provider presets inside the model editor:

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

Server — model discovery for sub-path endpoints:

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 22:43:24 +08:00
leokun f6be6c2b27 release: v0.1.3 2026-08-26 22:09:36 +08:00
leokun ded534fecf feat: update UI 2026-08-26 22:01:14 +08:00
leokun 5450fc76e2 fix: shell 2026-08-26 20:20:38 +08:00
leokun df053c3720 fix: harden concurrent persistence and task recovery 2026-08-26 16:46:41 +08:00
212 changed files with 26241 additions and 1411 deletions
+30
View File
@@ -213,6 +213,36 @@ jobs:
--output legacy-update/update.json \
--notes "Cursor BYOK v${VERSION}"
- name: Normalize Tauri updater download URLs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p tauri-update
release_id=$(
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"v${VERSION}\") | .id"
)
test -n "${release_id}"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > tauri-update/release.json
asset_id=$(node -p 'require("./tauri-update/release.json").assets.find(({ name }) => name === "latest.json")?.id ?? ""')
test -n "${asset_id}"
gh api \
-H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
> tauri-update/latest.json
node scripts/release/normalize-tauri-update.mjs \
--manifest tauri-update/latest.json \
--release tauri-update/release.json \
--repository "${GITHUB_REPOSITORY}" \
--version "${VERSION}"
- name: Upload normalized Tauri updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" tauri-update/latest.json --clobber
- name: Upload legacy updater assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Generated
+101 -3
View File
@@ -926,7 +926,7 @@ dependencies = [
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.2"
version = "0.1.5-beta.1"
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"
+8 -1
View File
@@ -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
+214
View File
@@ -0,0 +1,214 @@
<div align="center">
# cursor-byok
cursor-byok 是一个运行在本机的 Cursor 模型网关,帮助你在 Cursor 中使用自己配置的模型服务。
[English README](./README.md) · [使用指南](https://docs.leokun.cn) · [下载](https://github.com/leookun/cursor-byok/releases/latest) · [提交问题](https://github.com/leookun/cursor-byok/issues)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![将 cursor-byok 连接到多种模型 API](./images/en-brand-1.png)
![cursor-byok 控制面板](./images/en-home-1.png)
## 项目简介
cursor-byok 是一个开源的本地模型网关。它在你的设备上运行服务,接收 Cursor 发出的 Agent 请求,将请求转发到你配置的模型服务,并尽可能保留 Cursor Agent 的工具调用、Skills、MCP 和多轮对话能力。
你可以连接兼容 OpenAI 或 Anthropic 协议的服务,自定义服务地址、模型 ID、API Key 和请求参数,也可以使用 Cursor 平台默认选项之外的模型通道。
> [!IMPORTANT]
> cursor-byok 免费且开源,但你连接的模型服务商可能会按用量收费。本项目是独立项目,与 Cursor 或其开发者没有关联,也未获得其认可。
## 主要功能
- **自定义模型通道**:配置自己的 API 地址、凭据和模型 ID。
- **多种 API 协议**:支持 OpenAI Responses API、OpenAI Chat Completions API 和 Anthropic Messages API 兼容服务。
- **模型管理**:添加、复制、编辑、排序模型配置,并批量测试连接。
- **连接性能测试**:查看首字延迟、生成速度、总耗时和原始服务商响应。
- **Agent 工作流**:继续使用工具调用、Skills、MCP 和多轮对话。
- **会话指标**:查看 Token 用量、缓存命中率、对话轮次和估算价值。
- **TAB 补全服务**:在公益服务、官方直连和自定义服务之间选择连接方式。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载适合你操作系统的最新版本。
2. 启动 cursor-byok,打开 **Cursor 配置**,按提示初始化本地 CA(证书颁发机构)。
3. 在模型设置中添加模型,填写服务地址、API Key 和模型名称,然后保存并运行 **测试**。
4. 确认测试通过后,保持 cursor-byok 运行。
5. 打开或重启 Cursor,在模型列表中选择已配置的模型,开始使用 Agent。
完整的安装步骤、配置说明和故障处理,请参阅[中文使用指南](https://docs.leokun.cn/zh/docs)。
> [!TIP]
> 首次完成配置后,建议完全退出并重新启动 Cursor,再新开一个对话。使用自定义模型时,请在模型列表中手动选择该模型,不要选择 **Auto**。
## 模型配置
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
![模型设置页面](./images/en-model-1.png)
### 类型与协议选择
| 模型系列 | 模型类型 | 请求协议 |
| --- | --- | --- |
| Claude 系列 | **Anthropic** | Messages API |
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
| 其他模型 | **OpenAI** | **Chat Completions API** |
GPT 系列建议使用 **Responses API**。如果使用 Chat Completions,可能无法保留提示词缓存,导致速度变慢和费用增加。
### 常用字段
- **模型类型**:选择 OpenAI 或 Anthropic,决定上游接口格式。
- **请求协议**:OpenAI 类型需要继续选择 Responses API 或 Chat Completions API。
- **服务器地址**:可以填写服务商基础地址,让应用按协议追加标准端点,也可以填写完整请求 URL 并原样使用。
- **API Key**:填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
- **模型名称**:填写服务商接口接受的模型标识,也可以使用 **获取模型** 读取服务商返回的模型列表。
- **显示名称**:Cursor 模型列表中显示的名称,不会改变发送给上游的模型标识。
- **备注**:显示在 Cursor 的模型说明中。
还可以根据模型能力设置上下文窗口 Token、最大输出 Token、推理或思考强度、自定义 Headers,以及 OpenAI 或 Anthropic 的额外参数。自定义 Headers 和额外参数必须是 JSON 对象,只应填写服务商明确支持的字段。
保存配置后运行 **测试**,确认地址、协议、API Key、模型标识和流式响应都正常,再在 Cursor 中使用该模型。
## TAB 补全服务
Cursor 的 Tab 补全由独立的 TAB 服务处理,不经过模型通道。你可以在 **系统设置 → TAB 设置** 中选择以下模式:
- **使用公益服务(默认)**:使用项目作者部署的公共服务,无需额外配置。
- **直连**:直接连接当前 Cursor 账号对应的官方 TAB 服务,适合账号拥有官方额度的情况。
- **自定义**:自行部署 [`cursor-tab-server`](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后填写 TAB 服务地址。
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保新的连接方式生效。
## 与官方账号并存
新版设计支持 cursor-byok 与 Cursor 官方服务并存:
- 直接在 Cursor 中登录自己的账号。如果之前使用旧版生成的 fake 账户,请先退出该账户,再登录自己的账号。
- 账号拥有官方额度时,官方模型和本地模型可以随时切换混用。
- **Auto 只使用官方模型**,不会自动使用你配置的本地模型。账号没有官方额度时,请手动选择自己配置的模型。
- 插件、代码库索引等 Cursor 功能可以继续使用。
## 数据流转
```text
Cursor 客户端
│
│ Agent 请求与工具结果
▼
cursor-byok 本地服务
│
│ OpenAI / Anthropic 兼容请求
▼
你配置的模型 API
```
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商,请根据对应服务商的隐私政策和计费规则使用。
## 项目结构
```text
cursor-byok/
├── apps/
│ ├── desktop/ # React、Vite、Tauri 桌面应用
│ └── docs/ # Next.js 与 Fumadocs 中文/英文文档站
├── server/ # 本地服务、Cursor 请求处理和模型转发
├── crates/
│ └── semble-core/ # 本地代码索引与搜索核心库
├── cursor-proto/ # Cursor 协议定义
├── benchmarks/ # 代码搜索和索引基准测试
├── images/ # README 展示图片
├── Cargo.toml # Rust 工作区配置
└── Makefile # 常用开发、检查和构建命令
```
## 本地开发
### 环境要求
- Rust 工具链和 Cargo
- Node.js 与 npm
- Tauri 2 的系统构建依赖
- Docker(仅在构建 Docker 镜像时需要)
### 安装依赖
```bash
npm --prefix apps/desktop install
npm --prefix apps/docs install
```
### 启动开发环境
启动桌面前端:
```bash
make dev-web
```
启动桌面应用:
```bash
make dev-desktop
```
启动文档站:
```bash
make dev-docs
```
文档站默认地址为 <http://localhost:3000>。
### 检查与构建
运行完整检查:
```bash
make check
```
分别构建各部分:
```bash
make build-web # 构建桌面前端
make build-server # 构建 Rust 本地服务
make build-docs # 构建文档站
make build-desktop # 构建 Tauri 桌面安装包
make build-docker # 构建 Docker 镜像
```
文档内容位于 `apps/docs/content/docs` 和 `apps/docs/content/blog`。修改文档侧边栏时同步更新 `apps/docs/content/docs/meta.json`。
## 路线图
项目将继续改进模型兼容性、Agent 工具、本地运行稳定性和自托管体验,并探索支持更多 IDE、聊天和 Agent 工作流。
计划与进展请参阅[发布路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与反馈
- [中文使用指南](https://docs.leokun.cn/zh/docs)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 社区](https://t.me/cursor_byok)
- QQ 群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
提交问题时,请附上操作系统、cursor-byok 版本、模型类型、请求协议、已脱敏的服务地址、错误信息和复现步骤。请勿公开 API Key 或其他凭据。
## 参与贡献
欢迎提交 Issue 和 Pull Request。提交代码前请先阅读项目中的开发说明,并运行 `make check` 确认格式、测试和前端构建检查通过。
## 许可证
本项目采用 [MIT License](./LICENSE) 开源。
+3 -3
View File
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![Connect cursor-byok to a wide range of model APIs](./images/en-brand-1.png)
![cursor-byok dashboard](./images/en-home.png)
![cursor-byok dashboard](./images/en-home-1.png)
## About
@@ -53,7 +53,7 @@ For complete installation steps, system configuration, and troubleshooting, see
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model.png)
![cursor-byok model settings](./images/en-model-1.png)
## How It Works
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: var(--boot-bg, #141414);
}
</style>
<script>
// 在应用样式加载前按主题参数铺底色,避免切换 poster 时闪出错误颜色
(function () {
var light = new URLSearchParams(location.search).get("theme") === "default-light";
document.documentElement.style.setProperty("--boot-bg", light ? "#f5f5f5" : "#141414");
var meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = light ? "#f5f5f5" : "#141414";
document.head.appendChild(meta);
})();
</script>
<title>Cursor BYOK 产品演示</title>
</head>
<body>
<div id="root"></div>
<script src="/src/demo/index.tsx" type="module"></script>
</body>
</html>
+2 -32
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.5-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.5-beta.1",
"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",
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.2",
"version": "0.1.5-beta.1",
"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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.2"
version = "0.1.5-beta.1"
edition = "2021"
publish = false
@@ -16,6 +16,7 @@
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:app:allow-set-dock-visibility",
"allow-open-terminal-with-command",
"clipboard-manager:allow-write-text",
"autostart:default",
+7 -4
View File
@@ -186,9 +186,12 @@ pub fn run() {
let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings())
.map(|settings| settings.silent_start)
.unwrap_or(false);
let desktop_settings =
tauri::async_runtime::block_on(server.store().desktop_settings())
.unwrap_or_default();
#[cfg(target_os = "macos")]
app.handle()
.set_dock_visibility(desktop_settings.show_dock_icon)?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone();
@@ -206,7 +209,7 @@ pub fn run() {
exiting: AtomicBool::new(false),
});
let window = create_main_window(app.handle(), address)?;
if silent_start && started_by_autostart {
if desktop_settings.silent_start && started_by_autostart {
tracing::info!("silent autostart enabled; keeping the main window hidden");
} else {
window.show()?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.2",
"version": "0.1.5-beta.1",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+8 -4
View File
@@ -116,6 +116,8 @@ export interface StatisticsStorage {
trace_count: number;
}
export type StatisticsStorageScope = "details" | "all";
export type ProxyMode = "system" | "custom";
export interface ProxySettings {
@@ -143,6 +145,7 @@ export interface TabSettings {
export interface DesktopSettings {
silent_start: boolean;
show_dock_icon: boolean;
}
export interface OverviewMetrics {
@@ -291,7 +294,8 @@ export const api = {
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
testModel: (hash: string) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams();
if (filter) {
@@ -299,8 +303,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" }),
@@ -328,7 +332,7 @@ export const api = {
ports: () => request<PortSettings>("/settings/ports"),
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
clearStatisticsStorage: (scope: StatisticsStorageScope) => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE", body: JSON.stringify({ scope }) }),
proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
@@ -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,5 +1,7 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../i18n/runtime";
import { useI18n } from "../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
@@ -54,8 +56,6 @@ const CALENDAR_CONFIG = {
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
@@ -72,9 +72,10 @@ function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[]) {
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
@@ -88,20 +89,22 @@ function buildCalendarLayout(data: ContributionDay[]) {
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data), [data]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout;
useLayoutEffect(() => {
@@ -1,11 +1,11 @@
import { BarChart, LineChart } from "echarts/charts";
import { BarChart, GaugeChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
import { 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,25 +1,26 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export function CursorModelCards({
models,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: {
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
@@ -28,7 +29,54 @@ export function CursorModelCards({
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
}) {
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</div>;
return <div className={styles.modelGroups}>
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
<div className={styles.modelGroupHeader}>
<Icon icon={group.icon} size="1.1em" />
<span>{group.label}</span>
</div>
<ModelGrid {...props} models={group.models} sortable={false} />
</section>)}
</div>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
@@ -37,7 +85,7 @@ export function CursorModelCards({
reorder.current = onReorder;
useEffect(() => {
if (!grid.current) return;
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
@@ -72,7 +120,7 @@ export function CursorModelCards({
sortable.current?.destroy();
sortable.current = null;
};
}, []);
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
@@ -84,14 +132,14 @@ export function CursorModelCards({
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<strong>{model.display_name}</strong>
<span>{model.model_id}</span>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
@@ -102,7 +150,7 @@ export function CursorModelCards({
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
@@ -112,3 +160,27 @@ export function CursorModelCards({
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -1,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 = {
@@ -24,7 +26,7 @@ export const emptyCursorModelDraft = (): CursorModelDraft => ({
base_url: "",
use_full_url: false,
api_key: "",
tooltip_data: t("备注"),
tooltip_data: "",
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
@@ -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,18 +141,18 @@ 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("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={combinedOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
@@ -6,11 +6,13 @@ import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
| { status: "error"; error: string }
| { status: "cancelled" };
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
const success = state.status === "success";
const summary = success
@@ -0,0 +1,47 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.label {
font-size: 12px;
color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.7));
}
.chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.16));
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
&:hover {
border-color: var(--vscode-focusBorder, #49b0ff);
}
&.active {
border-color: var(--vscode-focusBorder, #49b0ff);
color: var(--vscode-focusBorder, #49b0ff);
}
}
.icon {
width: 14px;
height: 14px;
border-radius: 3px;
object-fit: contain;
}
@@ -0,0 +1,25 @@
import type { ModelType } from "../../api";
import { modelPresets, presetEndpoint, trimTrailingSlash, type ModelPreset } from "../../utils/modelPresets";
import styles from "./CursorPresetChips.module.scss";
/** 常用服务商预设:点击按当前协议类型自动填充对应端点与默认模型 */
export function CursorPresetChips({ type, baseUrl, onPick }: { type: ModelType; baseUrl: string; onPick: (preset: ModelPreset) => void }) {
return <div className={styles.wrap}>
<span className={styles.label}>{t("常用预设")}</span>
<div className={styles.chips}>
{modelPresets.map((preset) => {
const active = trimTrailingSlash(baseUrl) === trimTrailingSlash(presetEndpoint(preset, type).baseUrl);
return <button
type="button"
key={preset.key}
className={active ? `${styles.chip} ${styles.active}` : styles.chip}
title={preset.keyHint}
onClick={() => onPick(preset)}
>
<img className={styles.icon} src={preset.icon} alt="" />
{preset.name}
</button>;
})}
</div>
</div>;
}
@@ -5,6 +5,29 @@
gap: 16px;
}
.groupActions {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
.gateActions {
display: flex;
align-items: center;
@@ -39,13 +62,34 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.modelGroups {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.modelGroupHeader {
display: flex;
align-items: center;
gap: 6px;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
}
.modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
height: 150px;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -64,19 +108,19 @@
flex-direction: column;
gap: 4px;
strong,
span {
.modelCardNameText,
.modelCardModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
.modelCardNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
.modelCardModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
font-size: type.$font-size-2xs;
}
}
.modelTypeBadge {
@@ -15,8 +15,8 @@
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left:0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
@@ -26,8 +26,8 @@
top: 0;
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left: 0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -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>
@@ -18,7 +18,7 @@
border-radius: 5px;
button {
height: 30px;
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
@@ -97,6 +97,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const hostRef = useRef<HTMLDivElement | null>(null)
const localViewportRef = useRef<HTMLDivElement | null>(null)
const localContentRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const thumbRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
@@ -107,6 +108,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
viewportRef: localViewportRef,
contentRef: localContentRef,
trackRef,
thumbRef,
contentHeight,
@@ -132,7 +134,10 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
)
const setContentRef = useCallback(
(node: HTMLDivElement | null) => assignRef(contentRef, node),
(node: HTMLDivElement | null) => {
localContentRef.current = node
assignRef(contentRef, node)
},
[contentRef]
)
@@ -33,6 +33,7 @@ export interface ScrollMetrics extends ScrollAreaState {
interface UseScrollableModelOptions {
viewportRef: RefObject<HTMLDivElement | null>
contentRef: RefObject<HTMLDivElement | null>
trackRef: RefObject<HTMLDivElement | null>
thumbRef: RefObject<HTMLDivElement | null>
contentHeight?: number
@@ -131,6 +132,7 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) {
const {
viewportRef,
contentRef,
trackRef,
thumbRef,
contentHeight,
@@ -319,28 +321,30 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
useEffect(() => {
const element = viewportRef.current
const content = contentRef.current
const track = trackRef.current
if (!element || !track) return
if (!element || !content || !track) return
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
const viewportEntry = entries.find((entry) => entry.target === element)
if (viewportEntry) {
onViewportResizeRef.current?.({
width: entry.contentRect.width,
height: entry.contentRect.height,
width: viewportEntry.contentRect.width,
height: viewportEntry.contentRect.height,
})
}
snapshot(false)
})
resizeObserver.observe(element)
resizeObserver.observe(content)
resizeObserver.observe(track)
snapshot(false)
return () => {
resizeObserver.disconnect()
}
}, [snapshot, trackRef, viewportRef])
}, [contentRef, snapshot, trackRef, viewportRef])
useEffect(() => {
snapshot(false)
@@ -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>
@@ -8,6 +8,7 @@
color: var(--vscode-foreground);
font-size: type.$font-size-xs;
cursor: pointer;
flex-shrink: 0;
input {
position: absolute;
@@ -34,8 +34,8 @@
}
.iconButton {
width: 30px;
height: 30px;
width: 25px;
height: 25px;
display: grid;
place-items: center;
padding: 0;
@@ -47,6 +47,16 @@
.input {
height: 34px;
padding: 0 10px;
&[type="number"] {
appearance: textfield;
}
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
.secret {
@@ -34,12 +34,14 @@
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
header {
flex: 0 0 auto;
padding: 10px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
font-size: type.$font-size-base;
font-weight: 630;
}
footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 8px;
@@ -52,6 +54,10 @@
width: min(1080px, calc(100vw - 48px));
}
.fullHeight {
height: calc(100vh - var(--window-header-height) - 24px);
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
@@ -59,14 +65,8 @@
}
.body {
flex: 0 1 auto;
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyViewport {
height: auto;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyContent {
+4 -3
View File
@@ -12,6 +12,7 @@ type ModalProps = {
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
fullHeight?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
@@ -36,7 +37,7 @@ function focusableElements(root: HTMLElement) {
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose);
@@ -87,10 +88,10 @@ export function Modal({ id, open, title, children, banner, busy, wide, role = "d
if (!open) return null;
return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<header id={titleId}>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>}
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
-1
View File
@@ -15,7 +15,6 @@ export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
// Menu icons intentionally use filled glyphs from different collections so they
// can be compared in context before settling on one visual family.
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
+291
View File
@@ -0,0 +1,291 @@
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") {
const scope = (body as { scope?: string } | null)?.scope ?? "details";
storage = scope === "all"
? { bytes: 0, call_count: 0, trace_count: 0 }
: { ...storage, bytes: 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 : cache_read = 1 : 99,使默认口径缓存命中率恰为 99%
input_tokens: Math.round(800 * wave),
cache_read_tokens: Math.round(79_200 * 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 });
}
+13
View File
@@ -0,0 +1,13 @@
import { installDemoApi } from "./api";
installDemoApi();
const params = new URLSearchParams(window.location.search);
const locale = params.get("locale") === "en-US" ? "en-US" : "zh-CN";
const theme = params.get("theme") === "default-light" ? "default-light" : "default-dark";
document.documentElement.dataset.platform = "macos";
localStorage.setItem("cursor-byok.locale", locale);
localStorage.setItem("cursor-byok.theme", theme);
void import("../index");
File diff suppressed because it is too large Load Diff
+26 -5
View File
@@ -10,13 +10,13 @@
"076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}",
"07c657ed4747126e": "Anthropic extra parameters",
"08d742566ffc9fa3": "Clear all statistics?",
"092b520558eff5f2": "Not tested",
"099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output",
"0d2dab3d62eb73d6": "All statistics cleared",
"0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management",
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
@@ -47,6 +47,8 @@
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23ae7a90b1b9816d": "Clear scope",
"23e49479e15e6770": "Version {version} is available",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
@@ -70,7 +72,7 @@
"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",
"382f2e3419a02fef": "Only clear detailed records",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
@@ -78,12 +80,15 @@
"3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all",
"3a8c76b2ce785f96": "Review and import",
"3b67824289b5fa1e": "Dock icon hidden",
"3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close",
"40a08e7cf320ae07": "Clear detailed records?",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
@@ -98,6 +103,7 @@
"4927a53bcc886afb": "Loading…",
"497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a8d6841b4023edf": "Confirm import",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread",
@@ -119,8 +125,8 @@
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service",
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
@@ -139,6 +145,8 @@
"656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"680680288a6d2ad2": "Show in Dock",
"68102220092c1f0f": "Detailed records cleared",
"68ad603fafe4e0d6": "Import legacy configuration",
"68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time",
@@ -147,6 +155,7 @@
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}",
"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",
@@ -157,11 +166,11 @@
"7cea2f3c46565d29": "OpenAI extra parameters",
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e0891860c9e6374": "TAB service address is required",
"7e1f06318e80c3af": "Statistics cleared",
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
@@ -181,6 +190,7 @@
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
"940a168911ade998": "Items per page",
"946b3ffc02f026c0": "Delete this model?",
"966498853d801a52": "TAB connection",
@@ -202,6 +212,7 @@
"a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average",
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test",
@@ -211,15 +222,19 @@
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
"ac58d0f9a3f8d389": "Enter model notes",
"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",
"b67e408132f4dbd7": "Clear all statistics?",
"b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics",
"b86967982067d295": " (estimated)",
@@ -227,6 +242,7 @@
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba5865fbc734e672": "For example: Primary model",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
@@ -247,15 +263,20 @@
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
"d0bfccc77315d887": "Last month",
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"d2d648bd1c94b7f9": "Authentication",
"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",
"d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
@@ -290,7 +311,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",
@@ -311,6 +331,7 @@
"fdc4cabc370fa3f7": "No options",
"fea405f9b01d1416": "Summary",
"fec45092945f8790": "User guide",
"fec7210590309465": "Clear all statistics",
"ff509c9ba052a21c": "Saving…",
"ff66b1c7e010bbc2": "Paste the authorization command into the terminal and enter your password, then click the button below",
"ff673195f05fb056": "Service port"
+26 -5
View File
@@ -10,13 +10,13 @@
"076832c1b2de22c3": "缓存写入:{tokens}",
"07879e064ae16542": "输出推算:{tokens}",
"07c657ed4747126e": "Anthropic 额外参数",
"08d742566ffc9fa3": "确定要清理所有统计数据吗?",
"092b520558eff5f2": "未测试",
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
"09ebc2643631ba25": "价值估算",
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
"0c70665b6eb65f1a": "否",
"0c72229b7db0e1a9": "模型输出",
"0d2dab3d62eb73d6": "全部统计数据已清理",
"0d5e2bdb15579fc4": "消息数",
"0e41f8e3d59ec47b": "存储管理",
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
@@ -47,6 +47,8 @@
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23ae7a90b1b9816d": "清理范围",
"23e49479e15e6770": "发现新版本 {version}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
@@ -70,7 +72,7 @@
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"393df9bb13ea4900": "命中",
"382f2e3419a02fef": "仅清理详细记录",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
@@ -78,12 +80,15 @@
"3a3f595df70ec8ff": "清理存储空间",
"3a5040b68abf75f9": "全选",
"3a8c76b2ce785f96": "查看并导入",
"3b67824289b5fa1e": "已隐藏 Dock 栏图标",
"3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭",
"40a08e7cf320ae07": "确定要清理详细记录吗?",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
@@ -98,6 +103,7 @@
"4927a53bcc886afb": "加载中…",
"497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a8d6841b4023edf": "确认导入",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读",
@@ -119,8 +125,8 @@
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务",
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -139,6 +145,8 @@
"656ab25e264cc4e4": "还没有可供 Cursor 使用的模型",
"65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}",
"680680288a6d2ad2": "在 Dock 栏显示",
"68102220092c1f0f": "详细记录已清理",
"68ad603fafe4e0d6": "导入旧版配置",
"68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间",
@@ -147,6 +155,7 @@
"6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}",
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"788db1cfec2a3db5": "主题",
@@ -157,11 +166,11 @@
"7cea2f3c46565d29": "OpenAI 额外参数",
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
"7e0891860c9e6374": "TAB 服务地址不能为空",
"7e1f06318e80c3af": "统计数据已清理",
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
"7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
@@ -181,6 +190,7 @@
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
"940a168911ade998": "每页条数",
"946b3ffc02f026c0": "确定删除这个模型吗?",
"966498853d801a52": "TAB 选择",
@@ -202,6 +212,7 @@
"a363743025795ec7": "我已初始化,刷新",
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均",
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试",
@@ -211,15 +222,19 @@
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
"ac58d0f9a3f8d389": "请输入模型备注",
"ac69f68b7010ec79": "下载并安装",
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
"aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。",
"aee88743413144a2": "刷新",
"b06325c5660f0c29": "直连",
"b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。",
"b4411558b932266f": "上游类型",
"b502b1d414664337": "提示词:{tokens}",
"b5141d3d19e9a048": "是",
"b6725f218ebaef26": "已显示 Dock 栏图标",
"b67e408132f4dbd7": "确定要清理全部统计数据吗?",
"b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据",
"b86967982067d295": "(估算)",
@@ -227,6 +242,7 @@
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba5865fbc734e672": "例如:主力模型",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
@@ -247,15 +263,20 @@
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"d0bfccc77315d887": "近一个月",
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"d2d648bd1c94b7f9": "认证",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d58c88688e1a949d": "常用预设",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
@@ -290,7 +311,6 @@
"ee239f3943293f87": "周日",
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f2bdc88464c51c2e": "显示 API Key",
"f3a76d896853c1df": "未命中",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
@@ -311,6 +331,7 @@
"fdc4cabc370fa3f7": "暂无选项",
"fea405f9b01d1416": "概览",
"fec45092945f8790": "使用教程",
"fec7210590309465": "清理全部统计数据",
"ff509c9ba052a21c": "保存中…",
"ff66b1c7e010bbc2": "请在终端中粘贴授权命令并输入密码,完成后点击下方按钮",
"ff673195f05fb056": "服务端口"
+1 -10
View File
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom";
import { desktopPlatform } from "../native/platform";
import styles from "./AppFrame.module.scss";
import { AppHeader } from "./AppHeader";
@@ -17,13 +18,3 @@ export function AppFrame() {
</div>
);
}
export type DesktopPlatform = "macos" | "windows" | "linux";
function desktopPlatform(): DesktopPlatform {
// return "windows";
const agent = navigator.userAgent;
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
if (/Windows/.test(agent)) return "windows";
return "linux";
}
+4 -1
View File
@@ -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="" />
@@ -156,11 +156,14 @@
.actions {
height: var(--app-toolbar-height);
min-width: var(--app-toolbar-height);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 2px 2px;
justify-content: center;
padding: 6px 6px;
gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
}
.pageActions {
@@ -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>;
}
+22 -5
View File
@@ -1,14 +1,19 @@
import { getVersion } from "@tauri-apps/api/app";
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
import { isTauri } from "@tauri-apps/api/core";
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { api } from "../api";
import { api, type DesktopSettings } from "../api";
import { desktopPlatform } from "./platform";
export function hasNativeAppLifecycle(): boolean {
return isTauri();
}
export function hasDockVisibilitySetting(): boolean {
return hasNativeAppLifecycle() && desktopPlatform() === "macos";
}
export async function currentAppVersion(): Promise<string> {
return hasNativeAppLifecycle() ? getVersion() : "dev";
}
@@ -21,12 +26,24 @@ export async function writeAutostart(enabled: boolean): Promise<void> {
await (enabled ? enable() : disable());
}
export async function readSilentStart(): Promise<boolean> {
return (await api.desktopSettings()).silent_start;
export async function readDesktopSettings(): Promise<DesktopSettings> {
return api.desktopSettings();
}
export async function writeSilentStart(silentStart: boolean): Promise<void> {
await api.setDesktopSettings({ silent_start: silentStart });
const settings = await readDesktopSettings();
await api.setDesktopSettings({ ...settings, silent_start: silentStart });
}
export async function writeDockIconVisibility(visible: boolean): Promise<void> {
const settings = await readDesktopSettings();
await setDockVisibility(visible);
try {
await api.setDesktopSettings({ ...settings, show_dock_icon: visible });
} catch (cause) {
await setDockVisibility(settings.show_dock_icon).catch(() => {});
throw cause;
}
}
export async function checkForUpdate(): Promise<Update | null> {
+13
View File
@@ -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";
}
+76 -22
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ModelInput } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards } from "../components/cursor/CursorModelCards";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import styles from "../components/cursor/CursorSettings.module.scss";
@@ -31,7 +31,19 @@ export function CursorSettingsPage() {
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false);
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready";
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
const canGroupByType = typeGroups.length > 1;
useEffect(() => {
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
setGrouping("flat");
}
}, [canGroupByProvider, canGroupByType, grouping]);
useEffect(() => {
if (caCommand) void api.copyCursorText(caCommand);
@@ -93,19 +105,45 @@ export function CursorSettingsPage() {
message(errorText(cause));
}
};
const testModel = async (model: Model, notify = true) => {
const cancelModelTest = async (modelHash: string) => {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
}
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
activeModelTests.current.set(model.model_hash, active);
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try {
const result = await api.testModel(model.model_hash);
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return true;
return "success";
} catch (cause) {
if (active.cancelling || active.controller.signal.aborted) {
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
return "cancelled";
}
const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return false;
return "failure";
} finally {
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
setTestingModelHashes((current) => {
const next = new Set(current);
next.delete(model.model_hash);
@@ -115,29 +153,33 @@ export function CursorSettingsPage() {
};
const saveAndTest = async () => {
setSavingAndTesting(true);
let saved: Model | null = null;
try {
const saved = await persist();
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
saved = await persist();
} catch (cause) {
message(errorText(cause));
} finally {
setSavingAndTesting(false);
}
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
setBatchTesting(true);
try {
const results = await Promise.all(models.map((model) => testModel(model, false)));
const successful = results.filter(Boolean).length;
const failed = models.length - successful;
message(failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 ? 2400 : 5000 });
const successful = results.filter((result) => result === "success").length;
const failed = results.filter((result) => result === "failure").length;
const cancelled = results.filter((result) => result === "cancelled").length;
message(cancelled > 0
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
: failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
setBatchTesting(false);
}
@@ -166,7 +208,8 @@ export function CursorSettingsPage() {
const list = <CursorModelCards
models={models}
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
@@ -194,13 +237,24 @@ export function CursorSettingsPage() {
</CursorCaGate></CursorCaProvider>;
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
const estimatedModelHeight = grouping === "flat"
? Math.max(380, Math.ceil(models.length / 3) * 196)
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
return <>
{models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
{models.length > 0 && <PageActions position="left">
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</div>
</PageActions>}
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
</>}
+1 -1
View File
@@ -118,7 +118,7 @@ export function HomePage() {
{
key: "metrics",
estimatedHeight: 130,
content: <HomeMetrics data={metrics} />,
content: <HomeMetrics data={metrics} refreshVersion={refreshVersion} />,
},
{
+20 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type TabSettings } from "../api";
import { api, type ProxySettings, type ProxySettingsInput, type StatisticsStorage, type StatisticsStorageScope, type TabSettings } from "../api";
import { PageContent } from "../components/layout/PageContent";
import { LegacyModelImport } from "../components/models/LegacyModelImport";
import { AppLifecycleSettingsCard } from "../components/settings/AppLifecycleSettingsCard";
@@ -27,6 +27,7 @@ export function SettingsPage() {
const [savingPorts, setSavingPorts] = useState(false);
const [storage, setStorage] = useState<StatisticsStorage | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const [clearScope, setClearScope] = useState<StatisticsStorageScope>("details");
const [clearing, setClearing] = useState(false);
const [outboundProxy, setOutboundProxy] = useState<ProxySettings | null>(null);
const [proxyDraft, setProxyDraft] = useState<ProxySettingsInput>({ mode: "system", address: "", auth_enabled: false, username: "", password: "" });
@@ -87,10 +88,10 @@ export function SettingsPage() {
const clearStorage = async () => {
try {
setClearing(true);
setStorage(await api.clearStatisticsStorage());
setStorage(await api.clearStatisticsStorage(clearScope));
setConfirmClear(false);
await appStore.refresh();
message(t("统计数据已清理"));
message(clearScope === "all" ? t("全部统计数据已清理") : t("详细记录已清理"));
} catch (cause) {
message(cause instanceof Error ? cause.message : String(cause));
} finally {
@@ -154,6 +155,10 @@ export function SettingsPage() {
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};
const clearTitle = clearScope === "all" ? t("确定要清理全部统计数据吗?") : t("确定要清理详细记录吗?");
const clearDescription = clearScope === "all"
? t("所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。")
: t("仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。");
const content = (
<div className={styles.page}>
<TitledCard title={t("调用观测")}>
@@ -278,7 +283,7 @@ export function SettingsPage() {
<button
type="button"
className={styles.textButton}
onClick={() => setConfirmClear(true)}
onClick={() => { setClearScope("details"); setConfirmClear(true); }}
>
{t("清理存储空间")}
</button>
@@ -286,7 +291,7 @@ export function SettingsPage() {
</TitledCard>
<ConfirmDialog
open={confirmClear}
title={t("确定要清理所有统计数据吗?")}
title={clearTitle}
busy={clearing}
cancelLabel={t("取消")}
confirmLabel={t("确认清理")}
@@ -294,11 +299,16 @@ export function SettingsPage() {
onConfirm={() => void clearStorage()}
>
<div className={styles.confirmContent}>
<small>
{t(
"所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
)}
</small>
<Select
value={clearScope}
ariaLabel={t("清理范围")}
options={[
{ value: "details", label: t("仅清理详细记录") },
{ value: "all", label: t("清理全部统计数据") },
]}
onChange={(value) => setClearScope(value as StatisticsStorageScope)}
/>
<small>{clearDescription}</small>
</div>
</ConfirmDialog>
</div>
+1 -1
View File
@@ -90,7 +90,7 @@
sideBar-border: #00000024,
list-activeSelectionBackground: #0000000f,
list-activeSelectionForeground: #191919,
list-hoverBackground: #E6E6E67A,
list-hoverBackground: #f3f3f3,
input-background: #ffffff,
input-foreground: #191919,
input-border: #00000024,
+1 -1
View File
@@ -3,5 +3,5 @@ $font-size-xs: 13px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-xxl: 24px;
$font-size-xxl: 20px;
$font-size-3xl: 30px;
+130
View File
@@ -0,0 +1,130 @@
import type { ModelType } from "../api";
import deepseekIcon from "../assets/provider-icons/deepseek.svg";
import huoshanIcon from "../assets/provider-icons/huoshan.png";
import kimiIcon from "../assets/provider-icons/kimi.svg";
import minimaxIcon from "../assets/provider-icons/minimax.svg";
import zhipuIcon from "../assets/provider-icons/zhipu.svg";
import { defaultCustomHeaders } from "./modelDefaults";
export interface ModelPresetEntry {
model_id: string;
display_name: string;
context_window_tokens: number | null;
max_output_tokens: number | null;
}
/** 一个服务商在某种协议(anthropic / openai)下的接入端点 */
export interface ModelPresetEndpoint {
baseUrl: string;
/** true 时 baseUrl 即完整请求 URL;false 时由请求协议追加标准端点路径 */
useFullUrl: boolean;
/** openai 协议的请求端点(useFullUrl 为 true 时忽略) */
openaiEndpoint: string;
/** 非空时启用自定义 Headers(claude-cli 伪装头) */
customHeaders: Record<string, string> | null;
}
export interface ModelPreset {
key: string;
name: string;
icon: string;
keyHint: string;
/** 五家服务商均同时提供 Anthropic 与 OpenAI 兼容协议 */
endpoints: { anthropic: ModelPresetEndpoint; openai: ModelPresetEndpoint };
models: ModelPresetEntry[];
}
const entry = (
modelId: string,
displayName: string,
contextWindowTokens: number | null,
maxOutputTokens: number | null,
): ModelPresetEntry => ({
model_id: modelId,
display_name: displayName,
context_window_tokens: contextWindowTokens,
max_output_tokens: maxOutputTokens,
});
const claudeHeaders = { ...defaultCustomHeaders };
/** anthropic 协议:填 Base URL,自动追加 /v1/messages */
const anthropic = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "", customHeaders: claudeHeaders });
/** openai 协议:填 Base URL,自动追加 /v1/chat/completions */
const openaiChat = (baseUrl: string): ModelPresetEndpoint => ({ baseUrl, useFullUrl: false, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
/** openai 协议:路径不规则,直接给完整请求 URL */
const openaiFullUrl = (url: string): ModelPresetEndpoint => ({ baseUrl: url, useFullUrl: true, openaiEndpoint: "/v1/chat/completions", customHeaders: null });
export const modelPresets: ModelPreset[] = [
{
key: "zhipu",
name: "智谱 GLM",
icon: zhipuIcon,
keyHint: "bigmodel.cn → GLM Coding Plan → API Key(套餐 Key 与普通 Key 不通用)",
endpoints: {
anthropic: anthropic("https://open.bigmodel.cn/api/anthropic"),
openai: openaiFullUrl("https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"),
},
models: [
entry("glm-5.3", "GLM 5.3", 1000000, 65536),
entry("glm-5.2", "GLM 5.2", 200000, 32768),
entry("glm-4.7", "GLM 4.7", 200000, 32768),
],
},
{
key: "kimi",
name: "Kimi (Moonshot)",
icon: kimiIcon,
keyHint: "Kimi Code 编程套餐页获取 API Key(api.kimi.com/coding 端点)",
endpoints: {
anthropic: anthropic("https://api.kimi.com/coding"),
openai: openaiChat("https://api.kimi.com/coding"),
},
models: [
entry("k3", "Kimi K3", 1048576, 65536),
entry("kimi-for-coding", "K2.7 Coding", 262144, 32768),
],
},
{
key: "deepseek",
name: "DeepSeek",
icon: deepseekIcon,
keyHint: "platform.deepseek.com → API Keys",
endpoints: {
anthropic: anthropic("https://api.deepseek.com/anthropic"),
openai: openaiChat("https://api.deepseek.com"),
},
models: [
entry("deepseek-v4-pro", "DeepSeek V4 Pro", 1000000, 65536),
entry("deepseek-v4-flash", "DeepSeek V4 Flash", 1000000, null),
],
},
{
key: "volcengine",
name: "火山引擎方舟",
icon: huoshanIcon,
keyHint: "火山方舟 Coding Plan(ark-code-latest 路由多款代码模型)",
endpoints: {
anthropic: anthropic("https://ark.cn-beijing.volces.com/api/coding"),
openai: openaiFullUrl("https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"),
},
models: [entry("ark-code-latest", "Ark Code Latest", 256000, 32768)],
},
{
key: "minimax",
name: "MiniMax",
icon: minimaxIcon,
keyHint: "platform.minimaxi.com → 订阅 Coding Plan → API Key",
endpoints: {
anthropic: anthropic("https://api.minimaxi.com/anthropic"),
openai: { baseUrl: "https://api.minimaxi.com", useFullUrl: false, openaiEndpoint: "/v1/responses", customHeaders: null },
},
models: [
entry("MiniMax-M3", "MiniMax M3", 1000000, 65536),
entry("MiniMax-M2.7", "MiniMax M2.7", 205000, 32768),
],
},
];
export const trimTrailingSlash = (url: string) => url.replace(/\/+$/, "");
export const presetEndpoint = (preset: ModelPreset, type: ModelType): ModelPresetEndpoint => preset.endpoints[type];
+1 -1
View File
@@ -8,5 +8,5 @@
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["vite.config.ts", "plugins/**/*.ts"]
"include": ["vite*.config.ts", "plugins/**/*.ts"]
}
+22
View File
@@ -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,
},
},
});
+34
View File
@@ -0,0 +1,34 @@
# deps
/node_modules
# generated desktop product demo
/public/product-demo
# generated content
.source
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# others
.env*.local
.vercel
next-env.d.ts
# cloudflare / opennext
/.open-next/
/.wrangler/
cloudflare-env.d.ts
+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+48
View File
@@ -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,79 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/components/mdx';
import { blogSource, formatBlogDate } from '@/lib/blog';
import { i18n, isLanguage, type Language } from '@/lib/i18n';
const copy: Record<Language, { back: string; team: string }> = {
zh: { back: '返回开发者博客', team: 'Cursor Byok 开发团队' },
en: { back: 'Back to Developer Blog', team: 'The Cursor Byok team' },
};
export default async function BlogPostPage(props: PageProps<'/[lang]/blog/[slug]'>) {
const { lang, slug } = await props.params;
if (!isLanguage(lang)) notFound();
const page = blogSource.getPage([slug], lang);
if (!page) notFound();
const t = copy[lang];
const prefix = `/${lang}`;
const MDX = page.data.body;
return (
<main className="mx-auto w-full max-w-3xl px-6 py-12 sm:py-20">
<article>
<Link
href={`${prefix}/blog`}
className="mb-10 inline-flex items-center gap-2 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<ArrowLeft className="size-4" />
{t.back}
</Link>
<header className="border-b pb-8">
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(page.path, lang)}
</time>
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{page.data.title}</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{page.data.description}</p>
<p className="mt-5 text-sm font-medium">{t.team}</p>
</header>
<div className="prose mt-10 min-w-0">
<InlineTOC items={page.data.toc} />
<MDX
components={getMDXComponents({
a: createRelativeLink(blogSource, page),
})}
/>
</div>
</article>
</main>
);
}
export function generateStaticParams() {
return i18n.languages.flatMap((lang) =>
blogSource.getPages(lang).map((page) => ({ lang, slug: page.slugs[0] })),
);
}
export async function generateMetadata(
props: PageProps<'/[lang]/blog/[slug]'>,
): Promise<Metadata> {
const { lang, slug } = await props.params;
if (!isLanguage(lang)) notFound();
const page = blogSource.getPage([slug], lang);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}
+68
View File
@@ -0,0 +1,68 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { isLanguage, type Language } from '@/lib/i18n';
const copy: Record<Language, { title: string; description: string; intro: string }> = {
zh: {
title: '开发者博客',
description: 'Cursor Byok 的架构决策、协议实现与开发进展。',
intro: '记录 Cursor Byok 的架构决策、协议实现和开发进展。',
},
en: {
title: 'Developer Blog',
description: 'Architecture decisions, protocol work, and development progress of Cursor Byok.',
intro: 'Notes on architecture decisions, protocol work, and development progress of Cursor Byok.',
},
};
export async function generateMetadata(props: PageProps<'/[lang]/blog'>): Promise<Metadata> {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
return {
title: copy[lang].title,
description: copy[lang].description,
};
}
export default async function BlogPage(props: PageProps<'/[lang]/blog'>) {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
const t = copy[lang];
const posts = sortBlogPages(blogSource.getPages(lang));
return (
<main className="mx-auto w-full max-w-5xl px-6 py-16 sm:py-24">
<div className="max-w-2xl">
<p className="font-mono text-sm font-medium text-fd-primary">DEVELOPER BLOG</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight">{t.title}</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">{t.intro}</p>
</div>
<div className="mt-12 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-7 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(post.path, lang)}
</time>
<div>
<h2 className="font-semibold">{post.data.title}</h2>
<p className="mt-1 text-sm leading-6 text-fd-muted-foreground">
{post.data.description}
</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
import { isLanguage, i18n } from '@/lib/i18n';
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
const base = baseOptions(isLanguage(lang) ? lang : i18n.defaultLanguage);
return (
<HomeLayout
{...base}
nav={{ ...base.nav, transparentMode: 'always' }}
className="home-fullbleed-nav"
>
{children}
</HomeLayout>
);
}
+252
View File
@@ -0,0 +1,252 @@
import Link from 'next/link';
import {
ArrowRight,
BookOpen,
Settings2,
Star,
Wrench,
} from 'lucide-react';
import { notFound } from 'next/navigation';
import { DesktopDemo } from '@/components/hero/DesktopDemo';
import { DownloadButton } from '@/components/hero/DownloadButton';
import { blogSource, formatBlogDate, sortBlogPages } from '@/lib/blog';
import { formatStars, getRepoStats } from '@/lib/github';
import { isLanguage, type Language } from '@/lib/i18n';
import { releaseUrl, repositoryUrl } from '@/lib/shared';
const copy: Record<
Language,
{
pillReleased: (version: string) => string;
pillFallback: string;
title: string;
subtitle: string;
readDocs: string;
flow: [string, string, string];
docsEyebrow: string;
docsTitle: string;
viewAll: string;
docs: { icon: typeof BookOpen; title: string; description: string; href: string }[];
blogEyebrow: string;
blogTitle: string;
}
> = {
zh: {
pillReleased: (version) => `${version} 已发布 · 开源 · 本地运行`,
pillFallback: '开源 · 本地运行 · 自由接入',
title: 'Cursor 服务端的开源替代',
subtitle:
'在本机运行自己的模型网关,用自己的 API Key 接入 OpenAI、Anthropic 兼容服务或自定义端点,完整保留 Cursor Agent 的工具调用、Skills 和 MCP。',
readDocs: '阅读文档',
flow: ['Cursor 客户端', 'Cursor Byok 本地服务', '你的模型 API(OpenAI / Anthropic 兼容)'],
docsEyebrow: 'DOCUMENTATION',
docsTitle: '文档',
viewAll: '查看全部',
docs: [
{
icon: BookOpen,
title: '快速开始',
description: '完成安装、初始化和第一次模型调用。',
href: '/docs',
},
{
icon: Settings2,
title: '模型配置',
description: '配置协议、服务地址、凭据和生成参数。',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: '故障排查',
description: '解决证书、连接和模型测试问题。',
href: '/docs/troubleshooting',
},
],
blogEyebrow: 'DEVELOPER BLOG',
blogTitle: '开发者博客',
},
en: {
pillReleased: (version) => `${version} released · Open source · Runs locally`,
pillFallback: 'Open source · Runs locally · Any provider',
title: "The open-source alternative to Cursor's backend",
subtitle:
'Run your own model gateway locally, connect OpenAI- and Anthropic-compatible services or custom endpoints with your own API keys, and keep Cursor Agent tool calling, Skills, and MCP intact.',
readDocs: 'Read the docs',
flow: ['Cursor client', 'Cursor Byok local service', 'Your model API (OpenAI / Anthropic compatible)'],
docsEyebrow: 'DOCUMENTATION',
docsTitle: 'Documentation',
viewAll: 'View all',
docs: [
{
icon: BookOpen,
title: 'Quick Start',
description: 'Install, initialize, and make your first model call.',
href: '/docs',
},
{
icon: Settings2,
title: 'Model Configuration',
description: 'Configure protocols, server addresses, credentials, and parameters.',
href: '/docs/model-configuration',
},
{
icon: Wrench,
title: 'Troubleshooting',
description: 'Resolve certificate, connection, and model test issues.',
href: '/docs/troubleshooting',
},
],
blogEyebrow: 'DEVELOPER BLOG',
blogTitle: 'Developer Blog',
},
};
export default async function HomePage(props: PageProps<'/[lang]'>) {
const { lang } = await props.params;
if (!isLanguage(lang)) notFound();
const t = copy[lang];
const prefix = `/${lang}`;
const posts = sortBlogPages(blogSource.getPages(lang)).slice(0, 3);
const { stars, version } = await getRepoStats();
return (
<main className="flex flex-1 flex-col">
<section className="relative -mt-14 overflow-hidden border-b px-4 pb-16 pt-34 sm:px-6 sm:pb-24 sm:pt-42">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-fd-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-fd-border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-40 [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,#000_20%,transparent_75%)]" />
<div className="absolute left-1/2 top-[-14rem] h-[26rem] w-[44rem] -translate-x-1/2 rounded-full bg-fd-primary/10 blur-3xl" />
</div>
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-3xl text-center">
<a
href={releaseUrl}
className="inline-flex items-center gap-2 rounded-full border bg-fd-card px-4 py-1.5 text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-fd-primary opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-fd-primary" />
</span>
{version ? t.pillReleased(version) : t.pillFallback}
<ArrowRight className="size-3.5" />
</a>
<h1 className="mt-6 text-4xl font-bold tracking-tight sm:text-6xl">{t.title}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
{t.subtitle}
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<DownloadButton lang={lang} />
<Link
href={`${prefix}/docs`}
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
>
{t.readDocs}
<ArrowRight className="size-4" />
</Link>
<a
href={repositoryUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border bg-fd-card px-5 py-3 font-medium transition-colors hover:bg-fd-accent"
>
<GitHubIcon className="size-4" />
GitHub
{stars !== null ? (
<span className="flex items-center gap-1 border-l pl-2.5 text-sm text-fd-muted-foreground">
<Star className="size-3.5" />
{formatStars(stars)}
</span>
) : null}
</a>
</div>
</div>
<DesktopDemo lang={lang} />
<div className="mx-auto mt-10 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 font-mono text-xs text-fd-muted-foreground sm:text-sm">
<span>{t.flow[0]}</span>
<ArrowRight className="size-3.5 shrink-0" />
<span className="font-medium text-fd-foreground">{t.flow[1]}</span>
<ArrowRight className="size-3.5 shrink-0" />
<span>{t.flow[2]}</span>
</div>
</div>
</section>
<section className="border-b px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">{t.docsEyebrow}</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.docsTitle}</h2>
</div>
<Link
href={`${prefix}/docs`}
className="hidden items-center gap-2 text-sm font-medium sm:flex"
>
{t.viewAll}
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{t.docs.map(({ icon: Icon, title, description, href }) => (
<Link
key={href}
href={`${prefix}${href}`}
className="group rounded-xl border bg-fd-card p-6 transition-colors hover:bg-fd-accent"
>
<Icon className="mb-5 size-5 text-fd-primary" />
<h3 className="flex items-center justify-between font-semibold">
{title}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</h3>
<p className="mt-2 text-sm leading-6 text-fd-muted-foreground">{description}</p>
</Link>
))}
</div>
</div>
</section>
<section className="px-6 py-16 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-6">
<div>
<p className="font-mono text-sm font-medium text-fd-primary">{t.blogEyebrow}</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight">{t.blogTitle}</h2>
</div>
<Link href={`${prefix}/blog`} className="flex items-center gap-2 text-sm font-medium">
{t.viewAll}
<ArrowRight className="size-4" />
</Link>
</div>
<div className="mt-10 divide-y border-y">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="group grid gap-3 py-6 transition-colors hover:text-fd-primary sm:grid-cols-[10rem_1fr_auto] sm:items-center"
>
<time className="text-sm text-fd-muted-foreground">
{formatBlogDate(post.path, lang)}
</time>
<div>
<h3 className="font-semibold">{post.data.title}</h3>
<p className="mt-1 text-sm text-fd-muted-foreground">{post.data.description}</p>
</div>
<ArrowRight className="hidden size-4 transition-transform group-hover:translate-x-1 sm:block" />
</Link>
))}
</div>
</div>
</section>
</main>
);
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className={className}>
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
</svg>
);
}
@@ -0,0 +1,65 @@
import { getPageImageUrl, getPageMarkdownUrl, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { gitConfig } from '@/lib/shared';
export default async function Page(props: PageProps<'/[lang]/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage toc={page.data.toc} full={page.data.full} tableOfContent={{ style: 'clerk' }}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/[lang]/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: getPageImageUrl(page).url,
},
};
}
+15
View File
@@ -0,0 +1,15 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { i18n, isLanguage } from '@/lib/i18n';
export default async function Layout({ params, children }: LayoutProps<'/[lang]/docs'>) {
const { lang } = await params;
const language = isLanguage(lang) ? lang : i18n.defaultLanguage;
return (
<DocsLayout tree={source.getPageTree(language)} {...baseOptions(language)}>
{children}
</DocsLayout>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import { i18nProvider } from 'fumadocs-ui/i18n';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { htmlLang, i18n, isLanguage } from '@/lib/i18n';
import { translations } from '@/lib/layout.shared';
import '../global.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.leokun.cn';
export async function generateMetadata(props: LayoutProps<'/[lang]'>): Promise<Metadata> {
const { lang } = await props.params;
if (lang === 'en') {
return {
metadataBase: new URL(siteUrl),
title: {
default: 'Cursor Byok Docs',
template: '%s | Cursor Byok',
},
description: 'Installation, model configuration, and troubleshooting guide for Cursor Byok.',
};
}
return {
metadataBase: new URL(siteUrl),
title: {
default: 'Cursor Byok 文档',
template: '%s | Cursor Byok',
},
description: 'Cursor Byok 的安装、模型配置与故障排查指南。',
};
}
export function generateStaticParams() {
return i18n.languages.map((lang) => ({ lang }));
}
export default async function Layout({ params, children }: LayoutProps<'/[lang]'>) {
const { lang } = await params;
if (!isLanguage(lang)) notFound();
return (
<html lang={htmlLang[lang]} suppressHydrationWarning>
<body className="flex min-h-screen flex-col">
<RootProvider i18n={i18nProvider(translations, lang)}>{children}</RootProvider>
</body>
</html>
);
}
@@ -0,0 +1,29 @@
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
import { i18n } from '@/lib/i18n';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(
_req: Request,
{ params }: RouteContext<'/[lang]/llms.mdx/docs/[[...slug]]'>,
) {
const { lang, slug } = await params;
const page = source.getPage(slug?.slice(0, -1), lang);
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return i18n.languages.flatMap((lang) =>
source.getPages(lang).map((page) => ({
lang,
slug: getPageMarkdownUrl(page).segments,
})),
);
}
@@ -0,0 +1,31 @@
import { getPageImageUrl, source } from '@/lib/source';
import { i18n } from '@/lib/i18n';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from 'fumadocs-ui/og';
import { appName } from '@/lib/shared';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/[lang]/og/docs/[...slug]'>) {
const { lang, slug } = await params;
const page = source.getPage(slug.slice(0, -1), lang);
if (!page) notFound();
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site={appName} />,
{
width: 1200,
height: 630,
},
);
}
export function generateStaticParams() {
return i18n.languages.flatMap((lang) =>
source.getPages(lang).map((page) => ({
lang,
slug: getPageImageUrl(page).segments,
})),
);
}
+4
View File
@@ -0,0 +1,4 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
+40
View File
@@ -0,0 +1,40 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html {
scrollbar-gutter: stable;
}
html > body[data-scroll-locked] {
margin-right: 0 !important;
--removed-body-scroll-bar-size: 0px !important;
}
/* 文档页目录:隐藏默认折线,改用单条直线导轨 */
#nd-toc a[href^='#'] > svg,
#nd-toc div.absolute:has(> svg) {
display: none;
}
#nd-toc a[href^='#'] {
border-inline-start: 1px solid var(--color-fd-border);
}
#nd-toc a[href^='#'][data-active='true'] {
border-inline-start-color: var(--color-fd-primary);
}
/* 首页导航:通铺整行、无背景无分隔线 */
.home-fullbleed-nav {
--fd-layout-width: 100%;
}
.home-fullbleed-nav #nd-nav > * {
border-bottom-color: transparent;
backdrop-filter: none;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+10
View File
@@ -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'));
}
+8
View File
@@ -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,67 @@
.stage {
position: relative;
width: 100%;
max-width: 960px;
margin: 64px auto 0;
}
.halo {
position: absolute;
inset: -10% -14%;
background: radial-gradient(
50% 55% at 50% 45%,
color-mix(in srgb, var(--color-fd-primary) 9%, transparent),
transparent 72%
);
pointer-events: none;
}
.viewport {
position: relative;
width: 100%;
aspect-ratio: 960 / 620;
overflow: hidden;
background: #f5f5f5;
border: 1px solid var(--color-fd-border);
border-radius: 14px;
box-shadow:
0 42px 90px -34px rgb(0 0 0 / 35%),
0 12px 32px -16px rgb(0 0 0 / 28%);
}
.viewport[data-dark] {
background: #141414;
box-shadow:
0 42px 90px -34px rgb(0 0 0 / 50%),
0 12px 32px -16px rgb(0 0 0 / 42%);
}
.demo {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
background: transparent;
border: 0;
}
.poster {
object-fit: cover;
transition: opacity 0.6s ease;
}
.posterHidden {
opacity: 0;
pointer-events: none;
}
@media (max-width: 680px) {
.stage {
margin-top: 42px;
}
.viewport {
border-radius: 10px;
}
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
import Image from 'next/image';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { demoLocale, type Language } from '@/lib/i18n';
import styles from './DesktopDemo.module.css';
// 小屏或触屏设备内嵌交互体验差,只展示 poster 静态图
const embedQuery = '(min-width: 680px) and (hover: hover)';
const alt: Record<Language, string> = {
zh: 'Cursor Byok 桌面端数据概览界面',
en: 'Cursor Byok desktop overview dashboard',
};
function subscribeEmbedQuery(callback: () => void) {
const media = window.matchMedia(embedQuery);
media.addEventListener('change', callback);
return () => media.removeEventListener('change', callback);
}
function subscribeTheme(callback: () => void) {
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}
export function DesktopDemo({ lang }: { lang: Language }) {
const stageRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const embeddable = useSyncExternalStore(
subscribeEmbedQuery,
() => window.matchMedia(embedQuery).matches,
() => true,
);
const dark = useSyncExternalStore(
subscribeTheme,
() => document.documentElement.classList.contains('dark'),
() => true,
);
useEffect(() => {
if (!embeddable) return;
const stage = stageRef.current;
if (!stage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setMounted(true);
observer.disconnect();
}
},
{ rootMargin: '240px' },
);
observer.observe(stage);
return () => observer.disconnect();
}, [embeddable]);
return (
<div ref={stageRef} className={styles.stage}>
<div className={styles.halo} aria-hidden />
<DemoViewport
// key 含挂载状态:视口变小卸载 iframe 时整体重置,poster 重新显示
key={`${dark ? 'dark' : 'light'}-${lang}-${embeddable && mounted ? 'live' : 'poster'}`}
dark={dark}
lang={lang}
mounted={embeddable && mounted}
/>
</div>
);
}
function DemoViewport({
dark,
lang,
mounted,
}: {
dark: boolean;
lang: Language;
mounted: boolean;
}) {
const [frameLoaded, setFrameLoaded] = useState(false);
const [revealed, setRevealed] = useState(false);
const theme = dark ? 'default-dark' : 'default-light';
const demoUrl = `/product-demo/demo/index.html?theme=${theme}&locale=${demoLocale[lang]}`;
const posterUrl = `/images/product-demo-${dark ? 'dark' : 'light'}-${lang}.png`;
useEffect(() => {
if (!frameLoaded) return;
// iframe onLoad 只代表文档加载完成,等内部应用渲染后再揭开 poster
const timer = setTimeout(() => setRevealed(true), 900);
return () => clearTimeout(timer);
}, [frameLoaded]);
return (
<div className={styles.viewport} data-dark={dark || undefined}>
{mounted ? (
<iframe
title={alt[lang]}
src={demoUrl}
className={styles.demo}
onLoad={() => setFrameLoaded(true)}
/>
) : null}
<Image
src={posterUrl}
alt={alt[lang]}
fill
priority
unoptimized
className={revealed ? `${styles.poster} ${styles.posterHidden}` : styles.poster}
/>
</div>
);
}
@@ -0,0 +1,37 @@
'use client';
import { useSyncExternalStore } from 'react';
import { Download } from 'lucide-react';
import type { Language } from '@/lib/i18n';
import { releaseUrl } from '@/lib/shared';
function detectPlatform(): string | null {
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macOS';
if (/Win/i.test(ua)) return 'Windows';
if (/Linux|X11/i.test(ua)) return 'Linux';
return null;
}
const labels: Record<Language, { platform: (platform: string) => string; fallback: string }> = {
zh: { platform: (platform) => `下载 ${platform} 版`, fallback: '下载最新版' },
en: { platform: (platform) => `Download for ${platform}`, fallback: 'Download' },
};
const subscribeNoop = () => () => {};
export function DownloadButton({ lang }: { lang: Language }) {
// 平台只在客户端可知;SSR 用通用文案,水合后替换为平台文案
const platform = useSyncExternalStore(subscribeNoop, detectPlatform, () => null);
const label = labels[lang];
return (
<a
href={releaseUrl}
className="inline-flex items-center gap-2 rounded-lg bg-fd-primary px-5 py-3 font-medium text-fd-primary-foreground transition-opacity hover:opacity-90"
>
<Download className="size-4" />
{platform ? label.platform(platform) : label.fallback}
</a>
);
}
+18
View File
@@ -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,117 @@
---
title: Why We Rebuilt the Codebase
description: From the v0.0.49 Go monolith to a Rust rewrite — a deep dive into the harness, the protocol layer, the agent loop engine, architectural invariants, and the ecosystem.
---
In the [v1.0 Roadmap](https://github.com/leookun/cursor-byok/discussions/32) we wrote:
> We have to admit that vibe coding brought huge advantages, but also a heavy burden. Everything except the code is now perfectly clear, so we decided to rebuild the codebase.
The product direction, what users need, how the protocol works — all clear. The code itself had become the biggest obstacle. This post goes deep: what exactly was wrong with the old version, how each layer of the new architecture is designed, and which invariants hold it together.
## v0.0.49: a monolith at its product ceiling
The pre-rewrite version (preserved on [archive/v0.0.49](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49)) was a Go monolith with a webview frontend: 17 packages under `internal/` — `mitm`, `certs`, `cursoraccount`, `modelchannel`, `backend`, `bridge`… The product model was **full replacement**: once started, it took over all of Cursor's backend traffic and substituted the login state with a generated fake account.
That premise dictated everything: a start/stop switch, a fake account system to maintain, and official plugins and codebase indexing going dark while taken over. Users chose between the "official world" and the "local world". No patch could change the premise — mixing official and local models freely required a new one.
Meanwhile, code grown through rapid iteration had no dedicated protocol layer: every Cursor client update (new task modes, multitask, checkpoints) meant poking holes throughout the business logic. Boundaries by convention, state scattered everywhere, regression by hand. That is what "a heavy burden" meant concretely.
## The architecture after the rebuild
The new version is a Rust workspace where the boundaries are the directories:
```text
cursor-byok
├── server/src/
│ ├── harness/ # Local CA and MITM proxy: first-stage traffic routing
│ ├── cursor/ # Cursor protocol layer: session actors, projection, prompt compiler, tool dispatch
│ ├── run/ # Agent loop engine: model cycles × tool rounds
│ ├── provider/ # Model adapters: three protocols normalized into one event stream
│ ├── store/ # SQLite: append-only revision DAG + content-addressed storage
│ └── web/ # Web retrieval
├── crates/
│ ├── semble-core/ # Code indexing and hybrid retrieval
│ └── semble-mcp/ # Exposed as an MCP server
└── apps/desktop/ # Tauri + React desktop app
```
Let's walk through it along the lifecycle of a request.
## Harness: "coexistence" is a two-stage routing decision
The new version takes over exactly one thing — model calls. In code, that sentence is a precise two-stage decision:
**Stage one, at the MITM proxy.** A local CA (a 3072-bit RSA root that never leaves your machine) lets the proxy inspect Cursor's HTTPS. Only `*.cursor.sh` traffic is ever TLS-intercepted (`is_cursor_host`); everything else is tunneled untouched. Within Cursor traffic, only a fixed path allowlist — agent runs (`AgentService/RunSSE`, `BidiService/BidiAppend`), model catalogs, account display endpoints — is rewritten to the local service. Sign-in, the plugin marketplace, codebase indexing, and the rest go straight to official servers. Tab completion follows the user's setting: "Direct" passes through; the public or self-hosted service routes out.
**Stage two, at the local service.** Even for locally routed requests, the server decodes the first `AgentClientMessage` and looks up its `model_id` in the local model store. Found: handle locally (BYOK). Not found — the user picked an official model — buffer and forward the request back to `api2.cursor.sh`. A Cursor run is a pair of requests (upstream BidiAppend + a long-lived RunSSE stream), and both must take the same path, so a `wait_route` synchronization point blocks the SSE stream until the run is classified `Local` or `Upstream`.
This is exactly how the fake account and the start/stop switch disappeared: Auto and official models keep flowing to official billing; only your configured models enter the local path — one login, one client, routed per request.
## The protocol layer: Cursor's protocol behind one door
`server/src/cursor/` translates Cursor's private protocol into neutral events the engine understands. When the client updates, this is the only layer that moves. A few designs worth unpacking:
**One actor per request.** Each Cursor run gets a `CursorActor` holding a command mailbox, a cancellation token, and a replayable `OutputHub` — late SSE subscribers get all buffered frames replayed, so reconnects lose nothing. Upstream messages pass through an `OrderedInbox` keyed by sequence number: out-of-order arrivals are buffered, duplicates dropped. The protocol layer tolerates network nondeterminism by construction.
**Projection is the heart of the layer.** Internally there is one canonical message type (`CanonicalMessage`); the checkpoints sent to Cursor and the requests sent to model providers are both **projections** of it. The Cursor-side checkpoint has a hard check: stable history may only grow. If re-projection finds that a previously generated root "changed" or "shrank", it errors out — failing loudly beats silently corrupting state.
**Prompts are compiled.** Seven modes — Agent, Ask, Plan, Debug, Multitask, Subagent, Compaction — each built from a prompt template, a runtime template, and a tool manifest. The compiler substitutes placeholders, trims tools by model capability (no image generation → the tool is removed), and appends dynamic MCP tools in **deterministic order**. Determinism is not a style preference; the caching section below shows it is a correctness requirement.
**Tools execute on the client.** Shell commands, file edits, and searches requested by the model are not executed by the server — they are encoded as instructions for the Cursor client to run in the user's environment, with results returned via BidiAppend. Concurrent edits to the same path are serialized by a scheduler, and streaming arguments are projected into live UI previews.
## The loop engine: a minimal agent kernel
`server/src/run/` is the execution kernel, fully independent of the Cursor protocol. A run is a loop:
```text
loop {
project history → call model (consume_model_cycle)
├── no tool calls → append final reply, finish
└── tool calls → execute tool round → commit results → continue
}
```
**The model cycle is a strict state machine.** `consume_model_cycle` consumes the normalized event stream and enforces protocol invariants: `Start` must come first, text/thinking/tool blocks must open and close in pairs, and the finish reason must agree with the presence of tool calls. On cancellation it cleanly closes any open blocks before exiting — otherwise Cursor would merge deltas from two different outputs.
**Commit barriers.** Every state commit (initial messages, settled tool results, compaction, the final reply) waits for the Cursor checkpoint worker to acknowledge the write before the engine proceeds. Checkpoint publication is a hard synchronization point — the engine never runs ahead of published state.
**Auto-compaction is an explicit prefix reset.** The engine calibrates token estimates with the latest real usage; when estimated input exceeds `context window − 10,000` reserved tokens, it compacts: retain **exactly one** latest request-context message, summarize the rest (capped at 4,096 output tokens, reasoning disabled), then rebuild the revision in deterministic order. If compaction is interrupted, a fallback summary of the most recent text keeps the loop going.
**Cancellation and recovery.** Cancellation tokens propagate from the session all the way into the provider stream; activating a new run in the same conversation cancels the previous one. After a restart, an in-flight tool round can be reconstructed from the Cursor checkpoint and resumed (`RunAction::Resume`) — which is only possible because projection is bidirectional.
## The invariant: prefix cache stability
This is where "structure determines correctness" shows most clearly. Providers cache prompts by prefix, and the price difference can be 10×. To hit the cache, history must be an **append-only log**: everything sent to the model last turn must be a byte-level prefix of the next turn.
Convention cannot defend that, so it is built into every layer:
- **Storage**: history is an immutable revision DAG — each revision is a complete ordered message list identified by a SHA-256 content digest; appends create child revisions, identical appends reuse existing nodes, and the only "rewrite" is compaction, which branches from the root. Runtime events have unique identities; retries are idempotent, and reusing an identity with different content is a hard error.
- **Projection**: the system prompt and stable tool prefix are byte-stable when inputs are unchanged. Request context (rules, skills, MCP metadata) is separated from the per-turn runtime message: unchanged content is never re-appended; changed content appends a new message right before the runtime message — old ones are never rewritten. Context going A → B → A appends a third distinct A.
- **Provider**: tool-call IDs are normalized before leaving the server; thinking-block signatures (Anthropic's `signature`, OpenAI Responses' encrypted reasoning items) are preserved as replay state and replayed verbatim next turn.
GPT-family cache hit rates staying consistently high is not luck — it is this entire chain. The repo even carries a dedicated engineering-constraint document (`.agents/skills/cursor-prefix-stability`); every change touching this chain goes through its checklist.
## The provider layer: three protocols, one event stream
Three adapters — Anthropic Messages, OpenAI Chat Completions, OpenAI Responses — emit one normalized event vocabulary (`Start`, open/delta/close for text, thinking and tool blocks, `Usage`, `Done`). The engine and protocol layer never know which upstream they are talking to — which is exactly why "pick OpenAI Chat for everything else" works architecturally.
Every call is observable: sanitized request headers and body, streamed response chunks (buffered with limits), time to first token, token usage — fully replayable in detailed mode. The desktop app's call-details page reads precisely this data.
## Ecosystem: the rebuild is more than code
**The official ecosystem is preserved as-is.** The biggest ecosystem consequence of coexistence is "no subtraction": plugins, Skills, MCP, codebase indexing, and Tab completion all keep working once you sign in with your own account. The old version could not do this because it replaced the entire backend.
**Semantic search is standalone and measurable.** semble implements code indexing and hybrid retrieval as an independent crate, exposed as an MCP server. It ships a reproducible benchmark (real React/Vue commits, queries hand-labeled to implementation lines): natural-language queries reach 95% Recall@5, literal and symbol queries reach 100% — with a public quality-gate file, so any retrieval regression fails CI.
**The Tab service is open at three levels.** Public service (hosted by the author), Direct (your own account's official service), or self-hosted (`cursor-tab-server` lives on the archive branch) — switchable in system settings.
**The engineering system is part of the ecosystem too.** The repo maintains domain-constraint documents (database schema, prefix-cache stability, release process, i18n…) shared by humans and AI working on the code; `make check` runs formatting, linting, the full test suite, and frontend type checks in one command. This documentation site — including the interactive demo on the homepage built from real desktop components and mock data — lives in the same repo and evolves with the product.
**Direction is driven by community discussion.** Tab support, Gemini, image generation, all task modes, and codebase indexing from the roadmap have all landed in the rebuilt version, with more IDEs planned.
## Was it worth it
The rebuild consumed weeks that could have gone into features. In exchange: the product model no longer limits itself; protocol updates have a fixed landing place (only `cursor/` moves); correctness is guarded by types, invariants, and regression tests instead of memory; and every new capability grows on a product that already works.
The problem used to be "everything is clear except the code" — now the code is clear too. Thoughts on the rebuild? Join the [Roadmap discussion](https://github.com/leookun/cursor-byok/discussions/32).
@@ -0,0 +1,117 @@
---
title: 为什么我们重构代码?
description: 从 v0.0.49 的 Go 单体到 Rust 重写——harness、协议层、Agent 循环引擎、架构不变量与生态的深度解析。
---
在 [v1.0 Roadmap](https://github.com/leookun/cursor-byok/discussions/32) 里我们写过这样一段话:
> 不得不承认 vibe coding 带来了巨大的优势,但也带来了沉重的负担。现在我认为除了代码之外的一切内容都十分清晰,所以决定在近期重构代码。
产品方向、用户需要什么、协议怎么工作——这些都清楚,唯独代码本身成了继续前进的最大阻力。这篇文章把这次重构讲透:旧版到底哪里不行,新架构的每一层是怎么设计的,以及哪些不变量在支撑它。
## v0.0.49:一个走到产品上限的单体
重构前的版本(保留在 [archive/v0.0.49](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49) 分支)是一个 Go 单体加 webview 前端:`internal/` 下 17 个包,`mitm`、`certs`、`cursoraccount`、`modelchannel`、`backend`、`bridge`……产品模式是**整体替换**——服务启动后接管 Cursor 的全部后端流量,用内置生成的 fake 账户顶替登录态。
这个前提决定了一切:需要「启动/停止服务」的开关,需要维护假账户体系,官方的插件、代码库索引在接管状态下全部失效。用户在「官方世界」和「本地世界」之间二选一。修补任何一处都动摇不了这个前提——想让官方模型和自己的模型自由混用,必须换前提。
同时,快速迭代长出来的代码没有独立的协议层:Cursor 客户端每次更新(新任务模式、multitask、checkpoint),都要在业务逻辑里到处开洞。模块边界靠约定、状态散落各处、回归靠人肉。这就是「沉重的负担」的具体含义。
## 重构后的架构
新版是一个 Rust workspace,边界即目录:
```text
cursor-byok
├── server/src/
│ ├── harness/ # 本地 CA 与 MITM 代理:流量的第一级路由
│ ├── cursor/ # Cursor 协议层:会话 actor、投影、提示词编译、工具调度
│ ├── run/ # Agent 循环引擎:模型周期 × 工具轮次
│ ├── provider/ # 模型适配:三种协议归一化为统一事件流
│ ├── store/ # SQLite:append-only 修订 DAG + 内容寻址存储
│ └── web/ # 网页检索能力
├── crates/
│ ├── semble-core/ # 代码索引与混合检索
│ └── semble-mcp/ # 以 MCP 服务形态暴露
└── apps/desktop/ # Tauri + React 桌面端
```
下面按请求的生命周期,逐层拆开。
## Harness:「并存」是一个两级路由决策
新版只接管一件事——模型调用。这句话在代码里是一个精确的两级决策:
**第一级在 MITM 代理层。** 本地 CA(3072 位 RSA 根证书,只存在于你的机器)让代理能解析 Cursor 发出的 HTTPS。但只有 `*.cursor.sh` 的流量会被 TLS 解析(`is_cursor_host`),其余一律原样隧道。解析后再看路径:只有一个固定白名单——Agent 运行(`AgentService/RunSSE`、`BidiService/BidiAppend`)、模型目录、账号信息展示等——会被改写到本地服务;登录、插件市场、代码库索引等其余请求原封不动发往官方服务器。Tab 补全按用户设置:选「直连」就直通官方,选公益或自建才路由出去。
**第二级在本地服务层。** 即使请求进了本地,服务端解码首个 `AgentClientMessage` 后按 `model_id` 查本地模型配置:查到,本地处理(BYOK);查不到——说明用户选的是官方模型——整个请求缓冲后转发回 `api2.cursor.sh`。Cursor 的运行由一对请求组成(上行 BidiAppend + 下行 RunSSE 流),两者必须走同一条路,所以有一个 `wait_route` 同步点:SSE 流会阻塞等待路由分类(`Local` 或 `Upstream`)后再跟随。
fake 账户、启停开关就是这样消失的:官方模型选 Auto 或官方型号照常走官方计费,选你配置的模型才进本地——同一个登录态,同一个客户端,逐请求分流。
## 协议层:把 Cursor 协议关进一个模块
`server/src/cursor/` 的职责是把 Cursor 的私有协议翻译成引擎能理解的中立事件,客户端更新时只有这一层需要动。几个值得展开的设计:
**每个请求一个 actor。** 每个 Cursor 运行请求对应一个 `CursorActor`,持有命令信箱、取消令牌和一个可重放的输出中枢(`OutputHub`)——SSE 订阅者迟到时,缓冲的帧会全部重放,客户端断线重连不丢内容。上行消息经过一个按序号重排的 `OrderedInbox`:乱序到达缓冲,重复到达丢弃,协议层天然容忍网络的不确定性。
**投影(projection)是协议层的核心。** 服务端内部只有一种「规范消息」(`CanonicalMessage`),发给 Cursor 的检查点、发给模型服务商的请求,都是从规范消息**投影**出来的两种视图。Cursor 侧的检查点还有一条硬性校验:稳定历史只能追加,如果重投影时发现某个已生成的根「变了」或「变少了」,直接报错——宁可失败也不静默破坏状态。
**提示词是编译出来的。** Agent、Ask、Plan、Debug、Multitask、Subagent、Compaction 七种模式,每种由 prompt 模板 + runtime 模板 + 工具清单构成,编译器负责占位符替换、按模型能力裁剪工具(不支持图片生成就移除对应工具)、以确定性顺序追加动态 MCP 工具。确定性不是风格偏好,后面讲缓存时会看到它是正确性要求。
**工具在客户端执行。** 模型发起的 shell、读写文件、搜索,服务端并不亲自执行,而是编码成指令下发给 Cursor 客户端,由客户端在用户环境里跑,结果再经 BidiAppend 回传。同路径的并发编辑有串行化调度,流式参数还会被投影成 UI 的实时预览。
## Loop 引擎:Agent 循环的最小内核
`server/src/run/` 是与 Cursor 协议无关的执行内核。一次运行就是一个循环:
```text
loop {
投影历史 → 调用模型(consume_model_cycle)
├── 无工具调用 → 追加最终回复,结束
└── 有工具调用 → 执行工具轮次(tool_round) → 提交结果 → 继续循环
}
```
**模型周期是严格的状态机。** `consume_model_cycle` 消费归一化事件流,校验协议不变量:必须先 Start、文本/思考/工具块必须成对开闭、`finish_reason` 与工具调用必须一致。取消发生时它会先干净地闭合未闭合的块再退出——否则 Cursor 端会把两次输出的增量拼在一起。
**提交屏障(commit barrier)。** 引擎每次提交状态(初始消息、工具结果落定、压缩、最终回复),都要等 Cursor 检查点工作线程确认写完才继续。检查点发布是引擎与协议层之间的硬同步点——引擎永远不会跑到已发布状态的前面。
**自动压缩是显式的前缀重置。** 引擎用最近一次真实用量校准 token 估算,当估算输入超过 `上下文窗口 − 10_000` 预留时触发压缩:保留**恰好一条**最新的 request-context 消息,其余历史交给模型摘要(上限 4096 token、关闭思考),然后以确定性顺序重建修订。压缩若被打断,退化为截取最近文本的兜底摘要,循环继续。
**取消与恢复。** 取消令牌从会话一路传到 provider 流;同一会话激活新运行会自动取消旧的。服务重启后,进行中的工具轮次能从 Cursor 检查点反解出来继续执行(`RunAction::Resume`)——这依赖投影是双向的。
## 不变量:前缀缓存稳定性
这是整次重构里最能体现「结构决定正确性」的部分。模型服务商的提示词缓存按前缀命中,收费差可达 10 倍。要吃到缓存,历史就必须是**只追加的日志**:上一轮发给模型的完整内容,必须是下一轮的字节级前缀。
约定守不住这件事,所以它被做进了每一层:
- **存储层**:会话历史是不可变的修订 DAG——每个修订是完整的有序消息列表,由 SHA-256 内容摘要标识;追加产生子修订,内容相同的追加会复用已有节点;唯一能「改写」的操作是压缩,而它是从根分支出新链。运行时事件有唯一标识,重试幂等,内容不同的复用直接报错。
- **投影层**:系统提示词与稳定工具前缀在输入不变时字节级稳定;规则/技能等请求上下文与当轮 runtime 消息分离,内容不变不重复追加,变了就在 runtime 消息前追加新的一条——从不改写旧的。上下文 A→B→A 会追加第三条 A,而不是复用第一条。
- **provider 层**:工具调用 ID 在发给服务商前统一归一化;思考块的签名(Anthropic 的 signature、OpenAI Responses 的加密推理项)被原样保存为回放状态,下一轮逐字回放。
新版 GPT 系列缓存命中率能稳定在高位,靠的不是运气,是这一整条链。仓库里甚至有一份专门的工程约束文档(`.agents/skills/cursor-prefix-stability`),任何触碰这条链的改动都要过它的检查单。
## Provider 层:三种协议,一种事件流
Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 三个适配器,输出统一的归一化事件(`Start`、文本/思考/工具块的开闭与增量、`Usage`、`Done`)。引擎和协议层完全不知道上游是谁——这就是「其他模型无脑选 OpenAI Chat」在架构上成立的原因。
每次调用都有观测:请求头体(脱敏)、逐块流式响应(带缓冲与限额)、首字延迟、token 用量,详细模式下可完整回放。桌面端的调用详情页读的就是这些数据。
## 生态:重构不只是代码
**官方生态原样保留。** 并存设计最大的生态意义是「不减法」:插件、Skills、MCP、代码库索引、Tab 补全,登录自己的账号后全部照常。旧版做不到,是因为它把整个后端都换掉了。
**语义搜索是独立的、可度量的。** semble 作为独立 crate 实现代码索引与混合检索,以 MCP 服务形态接入。它有一套可复现的对比基准(React/Vue 真实提交、人工标注到实现行):自然语言查询 Recall@5 达 95%,字面与符号查询达 100%,并有公开的质量门槛文件——CI 里任何一次检索质量回退都会挡住合并。
**Tab 服务分层开放。** 公益服务(作者部署)、直连(自己账号的官方服务)、自建(`cursor-tab-server` 留在归档分支)三种模式,在系统设置里一键切换。
**工程体系也是生态。** 仓库里维护着一组领域约束文档(数据库 schema、前缀缓存稳定性、发布流程、i18n……),人和 AI 协作时共用同一套检查单;`make check` 一条命令跑完格式、静态检查、全量测试和前端类型检查。这个文档站本身——包括首页那个用真实桌面组件加 mock 数据构建的可交互 demo——也在同一个仓库里,与产品同步演进。
**方向由社区讨论驱动。** Roadmap 里的 Tab 支持、Gemini、图片生成、全部任务模式、代码库索引都已在重构后的版本里落地,更多 IDE 的支持在计划中。
## 值得吗
重构耗掉了数周本可以用来加功能的时间。换来的是:产品模式不再自我设限;协议更新有了固定的落点(只动 `cursor/`);正确性由类型、不变量和回归测试守护,而不是靠记忆;每个新能力都长在一个已经能工作的产品上。
「除了代码之外的一切都清晰」的问题,现在代码也清晰了。对这次重构有想法?欢迎到 [Roadmap 讨论区](https://github.com/leookun/cursor-byok/discussions/32) 聊聊。
+93
View File
@@ -0,0 +1,93 @@
---
title: Quick Start
description: Install Cursor Byok and let Cursor use your own model APIs.
icon: Rocket
---
Cursor Byok is a Cursor model gateway that runs on your machine. It receives Cursor Agent requests, converts them, and forwards them to the OpenAI- or Anthropic-compatible service you configure.
<Callout type="warn" title="Before you start">
Cursor Byok is an independent open-source project and is not affiliated with Cursor or its developers. The software itself is free, but model providers may charge for usage.
</Callout>
## Install and configure
<Steps>
<Step>
### Update Cursor
Download the latest official Cursor from [cursor.com](https://cursor.com) and install it over your existing copy, so the client stays up to date.
</Step>
<Step>
### Download and launch Cursor Byok
Download the latest release for your operating system from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest), then launch cursor-byok.
</Step>
<Step>
### Initialize and configure a model
Open **Cursor Configuration**, follow the prompts to initialize the local CA, then add a model. Fill in the server address, API key, and model name, and run the connectivity test until it passes.
![Cursor Configuration page](/images/docs/cursor-config-en.png)
See [Model Configuration](./model-configuration.mdx) for how to choose the type and protocol for different models.
</Step>
<Step>
### Restart Cursor after the first setup
After completing the configuration for the first time, quit and restart Cursor once so the model list takes effect.
</Step>
<Step>
### Use it in Cursor
Keep Cursor Byok running, start a new conversation in Cursor, pick the model you just configured from the model list (do not pick Auto), and start using Agent.
</Step>
</Steps>
## Coexists with your official account
The current design goal is coexistence with the official service — there is no more "stop the service" or account juggling:
- Sign in to Cursor with your own account. If you previously used a fake account generated by an old version, sign out of it first, then sign in with your own.
- If your account has official quota, there is no boundary between official models and your local models — switch and mix freely.
- **Auto always routes to official models**: picking Auto without quota results in an error, so select your configured model instead.
- Cursor features such as plugins and codebase indexing keep working as usual.
## Next steps
<Cards>
<Card title="Installation" description="Download, initialize, and run for the first time." href="/docs/installation" />
<Card title="Model Configuration" description="Choose a protocol and fill in upstream model parameters." href="/docs/model-configuration" />
<Card title="TAB Service" description="Choose how Cursor connects to Tab completion endpoints." href="/docs/tab-service" />
<Card title="Troubleshooting" description="Resolve certificate, connection, and model test issues." href="/docs/troubleshooting" />
</Cards>
## How data flows
```text
Cursor client
│ Agent requests and tool results
▼
Cursor Byok local service
│ OpenAI- / Anthropic-compatible requests
▼
Your model API
```
API keys, model configurations, and app settings are stored on your machine. Model requests are still sent to the upstream provider you choose.
+93
View File
@@ -0,0 +1,93 @@
---
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>
### 更新 Cursor
从 [cursor.com](https://cursor.com) 下载官方最新版 Cursor,直接覆盖安装,保持客户端为最新版本。
</Step>
<Step>
### 下载并启动 Cursor Byok
从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载与你的操作系统对应的最新版本,然后启动 Cursor Byok。
</Step>
<Step>
### 初始化并配置模型
打开 **Cursor 配置**,按界面提示初始化本地 CA,然后添加模型。填写服务地址、API Key 和模型名称,并运行连通性测试,测试通过即可。
![Cursor 配置页面](/images/docs/cursor-config-zh.png)
不同模型如何选择类型与协议,见[模型配置](./model-configuration.mdx)。
</Step>
<Step>
### 首次配置后重启 Cursor
首次完成配置时,完全退出并重新启动一次 Cursor,让模型列表生效。
</Step>
<Step>
### 在 Cursor 中使用
保持 Cursor Byok 运行,在 Cursor 中新开一个对话,从模型列表中选择你刚配置的模型(不要选 Auto),开始使用 Agent。
</Step>
</Steps>
## 与官方账号并存
新版的设计目标是与官方服务并存,不再需要“关服务”“切账号”之类的操作:
- 直接在 Cursor 中登录你自己的账号。如果之前用过旧版生成的 fake 账户,先退出它,再登录自己的账号。
- 账号有官方额度时,官方模型和本地模型没有使用上的边界,可以随时切换混用。
- **Auto 走的是官方模型**:账号没有额度时选 Auto 会报错,请手动选择你配置的模型。
- 插件、代码库索引等 Cursor 功能都可以正常使用。
## 接下来
<Cards>
<Card title="安装指南" description="下载、初始化和首次运行。" href="/docs/installation" />
<Card title="模型配置" description="选择协议并填写上游模型参数。" href="/docs/model-configuration" />
<Card title="TAB 服务" description="选择 Cursor Tab 补全接口的连接方式。" href="/docs/tab-service" />
<Card title="故障排查" description="处理证书、连接与模型测试问题。" href="/docs/troubleshooting" />
</Cards>
## 数据如何流转
```text
Cursor 客户端
│ Agent 请求与工具结果
▼
Cursor Byok 本地服务
│ OpenAI / Anthropic 兼容请求
▼
你配置的模型 API
```
API Key、模型配置和应用设置保存在本机。模型请求仍会发送到你选择的上游服务商。
@@ -0,0 +1,68 @@
---
title: Installation
description: Download Cursor Byok, complete local initialization, and verify it is running.
icon: Download
---
## Download the app
Go to the [latest release page](https://github.com/leookun/cursor-byok/releases/latest) and download the installer for macOS, Windows, or Linux.
<Callout type="info">
Prefer the latest stable release. The release page lists the installers and release notes for each version.
</Callout>
## First launch
<Steps>
<Step>
### Open Cursor Settings
After launching the app, go to **Cursor Settings**. If the local CA has not been initialized, the page shows an initialization entry.
</Step>
<Step>
### Initialize the local CA
Click **Initialize CA**. The local CA is used to inspect HTTPS requests from Cursor on your device; its files never leave your machine.
When the system asks for authorization, follow the instructions shown in the app to complete the trust step in your terminal, then return to the app and click **I have initialized, refresh**.
</Step>
<Step>
### Add your first model
Click **Add Model**, choose the OpenAI or Anthropic type, fill in the upstream service parameters, and save. See [Model Configuration](./model-configuration.mdx) for details on each field.
</Step>
<Step>
### Run the connectivity test
Click **Test** on the model. Once the test passes, the model appears in Cursor's model list.
</Step>
</Steps>
## Verify the installation
After configuration, confirm that:
- The Cursor Settings page no longer shows the CA initialization prompt.
- At least one model passes the connectivity test.
- Cursor Byok stays running.
- The configured display name shows up in Cursor's model list.
If any of these steps fail, head to [Troubleshooting](./troubleshooting.mdx).
## Updates
Check for new versions in the app settings, or visit [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) directly. You do not need to delete existing model configurations before updating.
+68
View File
@@ -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)。更新前无需删除现有模型配置。
+5
View File
@@ -0,0 +1,5 @@
{
"title": "User Guide",
"root": true,
"pages": ["index", "installation", "model-configuration", "tab-service", "troubleshooting"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "使用指南",
"root": true,
"pages": ["index", "installation", "model-configuration", "tab-service", "troubleshooting"]
}
@@ -0,0 +1,83 @@
---
title: Model Configuration
description: Configure the model protocol, server address, credentials, and generation parameters.
icon: Settings2
---
Each model configuration is an independent upstream channel that can use a different provider, protocol, credentials, and generation parameters.
![Edit model dialog](/images/docs/model-edit-en.png)
## Choosing the type and protocol
Picking by model family avoids most compatibility issues:
| Model family | Model type | Request protocol |
| --- | --- | --- |
| Claude family | **Anthropic** | — |
| GPT / OpenAI family | **OpenAI** | **Responses API** |
| Everything else | **OpenAI** | **Chat Completions API** |
<Callout type="warn" title="Always use the Responses API for GPT models">
Running GPT models through Chat Completions loses prompt caching, which makes them noticeably slower and more expensive.
</Callout>
## Required fields
### Model type
Choose the upstream interface format:
- **OpenAI**: supports the Responses API and the Chat Completions API.
- **Anthropic**: supports Messages API-compatible services.
### Request protocol
For the OpenAI type, also choose **Responses API** or **Chat Completions API**. This option determines the request and response format; it does not change the server address you entered.
### Server address
You can enter the provider's base URL, or choose to use a full request URL:
- With a base URL, Cursor Byok appends the standard endpoint path for the chosen protocol.
- With a full request URL, Cursor Byok uses the address as-is.
Prefer the built-in provider presets in the UI to avoid protocol and endpoint mismatches.
### API key
Enter the access key required by the upstream service. The key is stored on your machine and used to send model requests.
### Model name
Enter the model identifier accepted by the provider's API. You can also click **Fetch Models** to load the model list returned by the API.
## Display information
- **Display name**: the name shown in Cursor's model list; it does not change the model identifier sent upstream.
- **Notes**: shown in Cursor's model description.
## Optional parameters
Fill in the following fields based on the model's capabilities; when left empty, the app or upstream defaults are used:
- Context window tokens
- Max output tokens
- Reasoning or thinking effort
- Custom headers
- Extra OpenAI or Anthropic parameters
<Callout type="warn" title="Extra parameter format">
Custom headers and extra parameters must be JSON objects. Extra parameters directly affect upstream requests, so only add fields the provider explicitly supports.
</Callout>
## Test the configuration
Run **Test** after saving. The result shows time to first token, generation speed, total duration, and the model output, which helps you confirm:
1. The address and protocol match.
2. The API key is valid.
3. The model identifier exists and is accessible.
4. The upstream returns streamed content correctly.
Once the test passes, switch to Cursor and start using the model.
@@ -0,0 +1,83 @@
---
title: 模型配置
description: 配置模型协议、服务地址、凭据与生成参数。
icon: Settings2
---
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
![编辑模型弹窗](/images/docs/model-edit-zh.png)
## 如何选择类型与协议
按上游模型系列选择,可以避免绝大多数兼容性问题:
| 模型系列 | 模型类型 | 请求协议 |
| --- | --- | --- |
| Claude 系列 | **Anthropic** | — |
| GPT / OpenAI 系列 | **OpenAI** | **Responses API** |
| 其他所有模型 | **OpenAI** | **Chat Completions API** |
<Callout type="warn" title="GPT 系列务必用 Responses API">
GPT 系列走 Chat Completions 会丢失提示词缓存,速度和费用都会明显变差。
</Callout>
## 必填字段
### 模型类型
选择上游接口格式:
- **OpenAI**:支持 Responses API 和 Chat Completions API。
- **Anthropic**:支持 Messages API 兼容服务。
### 请求协议
OpenAI 类型需要继续选择 **Responses API** 或 **Chat Completions API**。该选项决定请求和响应格式,不会单独改变你填写的服务地址。
### 服务器地址
可以填写服务商的基础地址,也可以选择使用完整请求 URL:
- 使用基础地址时,Cursor Byok 会根据协议追加标准端点路径。
- 使用完整请求 URL 时,Cursor Byok 会原样使用该地址。
优先使用界面中的常用服务商预设,减少协议与端点不匹配的情况。
### API Key
填写上游服务要求的访问密钥。密钥保存在本机,用于发送模型请求。
### 模型名称
填写服务商接口接受的模型标识。你也可以点击 **获取模型** 读取接口返回的模型列表。
## 展示信息
- **显示名称**:Cursor 模型列表中看到的名称,不会改变发送给上游的模型标识。
- **备注**:显示在 Cursor 的模型说明中。
## 可选参数
按模型能力填写以下字段;留空时使用应用或上游的默认值:
- 上下文窗口 Token
- 最大输出 Token
- 推理强度或思考强度
- 自定义 Headers
- OpenAI 或 Anthropic 额外参数
<Callout type="warn" title="额外参数格式">
自定义 Headers 和额外参数必须是 JSON 对象。额外参数会直接影响上游请求,只添加服务商明确支持的字段。
</Callout>
## 测试配置
保存后运行 **测试**。结果会显示首字延迟、生成速度、总耗时和模型输出,便于确认:
1. 地址和协议是否匹配。
2. API Key 是否有效。
3. 模型标识是否存在且可访问。
4. 上游是否能正常返回流式内容。
测试通过后再前往 Cursor 使用该模型。
+27
View File
@@ -0,0 +1,27 @@
---
title: TAB Service
description: Choose how Cursor connects to Tab completion endpoints.
icon: Zap
---
Cursor's Tab completion does not go through model channels — it is handled by a separate Tab service. Cursor Byok offers three connection modes under **System settings → TAB settings**.
![TAB settings in System settings](/images/docs/tab-settings-en.png)
## The three modes
### Use public service (default)
Uses the public Tab service hosted by the project author. Works out of the box with no configuration.
### Direct
Connects directly to the official Tab service of the account signed in to your Cursor client. Suitable if your account has official quota.
### Custom
Deploy your own [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server), then enter your service URL in **TAB service address**. The original endpoint path is appended to that address.
<Callout type="info">
After changing TAB settings, restart Cursor and start a new conversation to make sure the connection mode takes effect.
</Callout>
+27
View File
@@ -0,0 +1,27 @@
---
title: TAB 服务
description: 选择 Cursor Tab 补全接口的连接方式。
icon: Zap
---
Cursor 的 Tab 补全不走模型通道,而是由单独的 Tab 服务处理。Cursor Byok 在 **系统设置 → TAB 设置** 中提供三种连接方式。
![系统设置中的 TAB 设置](/images/docs/tab-settings-zh.png)
## 三种模式
### 使用公益服务(默认)
使用由项目作者部署的公共 Tab 服务,开箱即用,无需任何配置。
### 直连
直接使用你在 Cursor 客户端中登录账号的官方 Tab 服务。适合账号本身有官方额度的用户。
### 自定义
自行部署 [cursor-tab-server](https://github.com/leookun/cursor-byok/tree/archive/v0.0.49/cursor-tab-server),然后在 **TAB 服务地址** 中填入你的服务地址。原接口路径会追加到该地址之后。
<Callout type="info">
修改 TAB 设置后,建议重启 Cursor 并新开一个对话,确保连接方式生效。
</Callout>
@@ -0,0 +1,83 @@
---
title: Troubleshooting
description: Resolve issues with the local CA, the management service, model connections, and Cursor's model list.
icon: Wrench
---
## Local CA needs to be initialized
Go to **Cursor Settings** and click **Initialize CA**. The local CA stays on your machine and is used to inspect HTTPS requests from Cursor.
## Local CA needs to be trusted by the system
Click **Open terminal to install CA** and follow the terminal prompts to complete system authorization. When done, return to the app and click **I have initialized, refresh**.
If the status does not update, quit Cursor Byok completely, restart it, and open the Cursor Settings page again.
## Cannot connect to the local management service
1. Quit and restart Cursor Byok completely.
2. Check whether security software is blocking local loopback connections.
3. If you changed the management service port, reset it to `0` so the app picks an available port at startup.
4. Launch the app again and refresh the page.
## Model connectivity test fails
Check based on the test error:
- **Authentication error**: confirm the API key is valid and the account can access the target model.
- **Endpoint not found**: confirm the model type, request protocol, and server address match.
- **Model not found**: use **Fetch Models** to check the model identifiers returned by the upstream.
- **Invalid parameters**: temporarily remove custom headers and extra parameters, then test again.
- **Connection timeout**: check your network, system proxy, and the upstream service status.
## Models do not show up in Cursor or do not take effect
First confirm all of the following:
1. The local CA status is healthy.
2. At least one model configuration is saved.
3. The model connectivity test passes.
4. Cursor Byok is running.
If it still does not work, run the full restart sequence in order:
1. Quit Cursor Byok completely from the tray and start it again.
2. Quit and restart Cursor completely.
3. Start a new conversation and refresh the model list.
4. Pick your own configured model — **do not pick Auto**.
## Quota or account errors when picking Auto
Auto only routes to official Cursor models and never uses your locally configured models. If your account has no official quota, picking Auto fails — select your configured model from the model list instead.
If your account does have quota, official models and local models mix freely with no usage boundary.
## Still using an old fake account
The current design coexists with your official account — fake accounts and "stop the service" workflows are no longer needed:
1. Sign out of the fake account generated by an old version in Cursor.
2. Sign in with your own Cursor account.
Once signed in with your own account, Cursor features such as plugins and codebase indexing work as usual.
## Requests fail even though the test passes
The model test only verifies basic connectivity. Agent requests also involve longer context, tool definitions, and streaming responses. Check:
- Whether the upstream model supports tool calling.
- Whether the context window and max output tokens fit the model's limits.
- Whether custom parameters are compatible with the actual protocol.
- The upstream status code and response body in the call details.
## Report an issue
If the problem persists, file an issue on [GitHub Issues](https://github.com/leookun/cursor-byok/issues) and include:
- Your operating system and Cursor Byok version.
- The selected model type and request protocol.
- The redacted server address and error message.
- Steps to reproduce.
Never share API keys or other credentials publicly.
@@ -0,0 +1,83 @@
---
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 正在运行。
仍然不生效时,按顺序执行一遍完整的重启流程:
1. 从托盘完全退出 Cursor Byok,重新启动。
2. 完全退出并重新启动 Cursor。
3. 新开一个对话,刷新模型列表。
4. 选择你自己配置的模型,**不要选 Auto**。
## 选 Auto 时报额度或账户错误
Auto 只会路由到 Cursor 官方模型,不会使用你配置的本地模型。账号没有官方额度时选 Auto 就会报错——请在模型列表中手动选择你配置的模型。
如果账号本身有额度,官方模型和本地模型可以随意混用,没有使用上的边界。
## 还在使用旧版的 fake 账户
新版的设计是与官方账号并存,不再需要 fake 账户,也没有“关服务”之类的操作:
1. 在 Cursor 中退出旧版生成的 fake 账户。
2. 登录你自己的 Cursor 账号。
登录自己的账号后,插件、代码库索引等 Cursor 功能都可以正常使用。
## 请求失败但测试成功
模型测试只验证基础连接。Agent 请求还会包含更长上下文、工具定义和流式响应。请检查:
- 上游模型是否支持工具调用。
- 上下文窗口和最大输出 Token 是否符合模型限制。
- 自定义参数是否与实际协议兼容。
- 调用详情中的上游状态码和响应内容。
## 继续反馈
如果问题仍然存在,请在 [GitHub Issues](https://github.com/leookun/cursor-byok/issues) 提交问题,并附上:
- 操作系统与 Cursor Byok 版本。
- 选择的模型类型和请求协议。
- 已脱敏的服务地址与错误信息。
- 复现步骤。
请勿公开 API Key 或其他凭据。
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
'.source/**',
'public/product-demo/**',
'.open-next/**',
'.wrangler/**',
'cloudflare-env.d.ts',
]),
]);
export default eslintConfig;
+37
View File
@@ -0,0 +1,37 @@
import { loader } from 'fumadocs-core/source';
import { pageSchema } from 'fumadocs-core/source/schema';
import { defineCollections } from 'fumadocs-mdx/macro';
import { htmlLang, i18n, type Language } from './i18n';
const blog = defineCollections({
type: 'doc',
dir: 'content/blog',
schema: pageSchema,
});
export const blogSource = loader({
baseUrl: '/blog',
source: blog.toFumadocsSource(),
i18n,
});
export function getBlogDate(path: string): Date {
const fileName = path.split('/').at(-1) ?? '';
const match = /^(\d{4}-\d{2}-\d{2})/.exec(fileName);
return match ? new Date(`${match[1]}T00:00:00Z`) : new Date(0);
}
export function formatBlogDate(path: string, lang: Language = i18n.defaultLanguage): string {
return new Intl.DateTimeFormat(htmlLang[lang], {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(getBlogDate(path));
}
export function sortBlogPages<T extends { path: string }>(pages: T[]): T[] {
return [...pages].sort((left, right) => {
return getBlogDate(right.path).getTime() - getBlogDate(left.path).getTime();
});
}
+1
View File
@@ -0,0 +1 @@
export { cn } from 'cnfast';

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