Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59379f1f72 | ||
|
|
370f120b8a | ||
|
|
1ab86c1851 | ||
|
|
d015d1c33b | ||
|
|
1fcb78ad95 | ||
|
|
b2610c562e | ||
|
|
318545cebd | ||
|
|
2369d101ec | ||
|
|
80604bdf97 | ||
|
|
c4031d4074 | ||
|
|
b15e149b9d | ||
|
|
15f2280e07 | ||
|
|
71ddf71ec1 | ||
|
|
cde4d55fd2 | ||
|
|
217f984112 | ||
|
|
2b54014cf9 | ||
|
|
6b65a4ae31 | ||
|
|
8ca62e933b | ||
|
|
c711690f90 | ||
|
|
ed300cc92e | ||
|
|
eca063e424 | ||
|
|
6c758f2b44 | ||
|
|
9cb16e116b | ||
|
|
c5d578c5b1 | ||
|
|
661ce11207 | ||
|
|
477750860f | ||
|
|
02a190489a | ||
|
|
ee915ee760 | ||
|
|
aa68205735 | ||
|
|
e4515dc4e0 | ||
|
|
7914f24d83 | ||
|
|
b85cb37898 |
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5-beta.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key
|
||||
|
||||
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
|
||||
.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker
|
||||
|
||||
check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --all-targets
|
||||
npm --prefix apps/desktop run check
|
||||
npm --prefix apps/docs run check
|
||||
|
||||
dev-web:
|
||||
npm --prefix apps/desktop run dev:web
|
||||
|
||||
dev-docs:
|
||||
npm --prefix apps/docs run dev
|
||||
|
||||
dev-server:
|
||||
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
|
||||
|
||||
@@ -20,6 +24,9 @@ dev-desktop:
|
||||
build-web:
|
||||
npm --prefix apps/desktop run build
|
||||
|
||||
build-docs:
|
||||
npm --prefix apps/docs run build
|
||||
|
||||
build-server:
|
||||
cargo build --release --package cursor-server --bin cursor-server
|
||||
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 项目简介
|
||||
|
||||
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**。
|
||||
|
||||
## 模型配置
|
||||
|
||||
每个模型配置都是独立的上游通道,可以单独设置服务商、协议、凭据和生成参数。
|
||||
|
||||

|
||||
|
||||
### 类型与协议选择
|
||||
|
||||
| 模型系列 | 模型类型 | 请求协议 |
|
||||
| --- | --- | --- |
|
||||
| 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) 开源。
|
||||
@@ -17,9 +17,9 @@ cursor-byok is a local implementation of Cursor's backend.
|
||||
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5-beta.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.4",
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5-beta.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5-beta.1",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface StatisticsStorage {
|
||||
trace_count: number;
|
||||
}
|
||||
|
||||
export type StatisticsStorageScope = "details" | "all";
|
||||
|
||||
export type ProxyMode = "system" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
@@ -330,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,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 });
|
||||
}
|
||||
@@ -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");
|
||||
@@ -92,7 +92,7 @@
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 180,
|
||||
"line": 185,
|
||||
"column": 82
|
||||
}
|
||||
]
|
||||
@@ -169,18 +169,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"08d742566ffc9fa3": {
|
||||
"source": "确定要清理所有统计数据吗?",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 289,
|
||||
"column": 16
|
||||
}
|
||||
]
|
||||
},
|
||||
"092b520558eff5f2": {
|
||||
"source": "未测试",
|
||||
"kind": "text",
|
||||
@@ -277,6 +265,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"0d2dab3d62eb73d6": {
|
||||
"source": "全部统计数据已清理",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 94,
|
||||
"column": 38
|
||||
}
|
||||
]
|
||||
},
|
||||
"0d5e2bdb15579fc4": {
|
||||
"source": "消息数",
|
||||
"kind": "text",
|
||||
@@ -301,7 +301,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 272,
|
||||
"line": 277,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -574,7 +574,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 276,
|
||||
"line": 281,
|
||||
"column": 60
|
||||
}
|
||||
]
|
||||
@@ -600,17 +600,17 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 63,
|
||||
"line": 64,
|
||||
"column": 42
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 185,
|
||||
"line": 190,
|
||||
"column": 22
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 212,
|
||||
"line": 217,
|
||||
"column": 58
|
||||
}
|
||||
]
|
||||
@@ -697,7 +697,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 250,
|
||||
"line": 255,
|
||||
"column": 43
|
||||
}
|
||||
]
|
||||
@@ -709,7 +709,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 118,
|
||||
"line": 119,
|
||||
"column": 15
|
||||
}
|
||||
]
|
||||
@@ -726,6 +726,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"23ae7a90b1b9816d": {
|
||||
"source": "清理范围",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 304,
|
||||
"column": 24
|
||||
}
|
||||
]
|
||||
},
|
||||
"23e49479e15e6770": {
|
||||
"source": "发现新版本 {version}",
|
||||
"kind": "template",
|
||||
@@ -871,12 +883,12 @@
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 176,
|
||||
"line": 181,
|
||||
"column": 81
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 291,
|
||||
"line": 296,
|
||||
"column": 22
|
||||
}
|
||||
]
|
||||
@@ -945,7 +957,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 142,
|
||||
"line": 143,
|
||||
"column": 15
|
||||
}
|
||||
]
|
||||
@@ -1029,7 +1041,7 @@
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 235,
|
||||
"line": 240,
|
||||
"column": 27
|
||||
}
|
||||
]
|
||||
@@ -1095,6 +1107,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"382f2e3419a02fef": {
|
||||
"source": "仅清理详细记录",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 306,
|
||||
"column": 42
|
||||
}
|
||||
]
|
||||
},
|
||||
"393e1241552b1870": {
|
||||
"source": "请求",
|
||||
"kind": "text",
|
||||
@@ -1155,7 +1179,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 283,
|
||||
"line": 288,
|
||||
"column": 14
|
||||
}
|
||||
]
|
||||
@@ -1179,7 +1203,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 235,
|
||||
"line": 240,
|
||||
"column": 39
|
||||
}
|
||||
]
|
||||
@@ -1229,12 +1253,12 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 242,
|
||||
"line": 247,
|
||||
"column": 22
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 248,
|
||||
"line": 253,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -1287,6 +1311,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"40a08e7cf320ae07": {
|
||||
"source": "确定要清理详细记录吗?",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 158,
|
||||
"column": 66
|
||||
}
|
||||
]
|
||||
},
|
||||
"4125fc7ba333524c": {
|
||||
"source": "默认亮色",
|
||||
"kind": "text",
|
||||
@@ -1294,7 +1330,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 267,
|
||||
"line": 272,
|
||||
"column": 52
|
||||
}
|
||||
]
|
||||
@@ -1647,7 +1683,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 174,
|
||||
"line": 179,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -1729,7 +1765,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 228,
|
||||
"line": 233,
|
||||
"column": 78
|
||||
}
|
||||
]
|
||||
@@ -1775,7 +1811,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 232,
|
||||
"line": 237,
|
||||
"column": 21
|
||||
}
|
||||
]
|
||||
@@ -1835,18 +1871,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"5cae248525cb9140": {
|
||||
"source": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 298,
|
||||
"column": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
"5d59857bf039cac9": {
|
||||
"source": "Cursor 助手 v{version}",
|
||||
"kind": "template",
|
||||
@@ -1856,7 +1880,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "layouts/AppHeader.tsx",
|
||||
"line": 31,
|
||||
"line": 34,
|
||||
"column": 18
|
||||
}
|
||||
]
|
||||
@@ -2004,7 +2028,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "api.ts",
|
||||
"line": 315,
|
||||
"line": 317,
|
||||
"column": 43
|
||||
}
|
||||
]
|
||||
@@ -2112,6 +2136,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"68102220092c1f0f": {
|
||||
"source": "详细记录已清理",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 94,
|
||||
"column": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
"68ad603fafe4e0d6": {
|
||||
"source": "导入旧版配置",
|
||||
"kind": "text",
|
||||
@@ -2261,7 +2297,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 164,
|
||||
"line": 169,
|
||||
"column": 16
|
||||
}
|
||||
]
|
||||
@@ -2273,7 +2309,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 259,
|
||||
"line": 264,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -2297,7 +2333,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "api.ts",
|
||||
"line": 310,
|
||||
"line": 312,
|
||||
"column": 43
|
||||
}
|
||||
]
|
||||
@@ -2321,7 +2357,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 292,
|
||||
"line": 297,
|
||||
"column": 23
|
||||
}
|
||||
]
|
||||
@@ -2364,23 +2400,11 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 136,
|
||||
"line": 137,
|
||||
"column": 83
|
||||
}
|
||||
]
|
||||
},
|
||||
"7e1f06318e80c3af": {
|
||||
"source": "统计数据已清理",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 93,
|
||||
"column": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
"7e7df68f2a82e09e": {
|
||||
"source": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"kind": "text",
|
||||
@@ -2424,7 +2448,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "api.ts",
|
||||
"line": 259,
|
||||
"line": 261,
|
||||
"column": 21
|
||||
}
|
||||
]
|
||||
@@ -2596,7 +2620,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 231,
|
||||
"line": 236,
|
||||
"column": 22
|
||||
}
|
||||
]
|
||||
@@ -2709,6 +2733,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"92156a483d4ba248": {
|
||||
"source": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 161,
|
||||
"column": 7
|
||||
}
|
||||
]
|
||||
},
|
||||
"940a168911ade998": {
|
||||
"source": "每页条数",
|
||||
"kind": "text",
|
||||
@@ -2851,7 +2887,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 239,
|
||||
"line": 244,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -2887,7 +2923,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 56,
|
||||
"line": 57,
|
||||
"column": 34
|
||||
}
|
||||
]
|
||||
@@ -2957,7 +2993,7 @@
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 177,
|
||||
"line": 182,
|
||||
"column": 133
|
||||
}
|
||||
]
|
||||
@@ -3349,6 +3385,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"b67e408132f4dbd7": {
|
||||
"source": "确定要清理全部统计数据吗?",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 158,
|
||||
"column": 45
|
||||
}
|
||||
]
|
||||
},
|
||||
"b75a46aad3e7c132": {
|
||||
"source": "非缓存输入:{tokens}",
|
||||
"kind": "template",
|
||||
@@ -3370,7 +3418,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 275,
|
||||
"line": 280,
|
||||
"column": 22
|
||||
}
|
||||
]
|
||||
@@ -3496,7 +3544,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 267,
|
||||
"line": 272,
|
||||
"column": 40
|
||||
}
|
||||
]
|
||||
@@ -3695,12 +3743,12 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 162,
|
||||
"line": 167,
|
||||
"column": 22
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 168,
|
||||
"line": 173,
|
||||
"column": 20
|
||||
}
|
||||
]
|
||||
@@ -3712,7 +3760,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 199,
|
||||
"line": 204,
|
||||
"column": 21
|
||||
}
|
||||
]
|
||||
@@ -3741,6 +3789,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"d1a3d72618d1ed27": {
|
||||
"source": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 160,
|
||||
"column": 7
|
||||
}
|
||||
]
|
||||
},
|
||||
"d2d648bd1c94b7f9": {
|
||||
"source": "认证",
|
||||
"kind": "text",
|
||||
@@ -3982,7 +4042,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 306,
|
||||
"line": 316,
|
||||
"column": 30
|
||||
}
|
||||
]
|
||||
@@ -4188,7 +4248,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 69,
|
||||
"line": 70,
|
||||
"column": 17
|
||||
}
|
||||
]
|
||||
@@ -4258,7 +4318,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 159,
|
||||
"line": 164,
|
||||
"column": 26
|
||||
}
|
||||
]
|
||||
@@ -4347,7 +4407,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 218,
|
||||
"line": 223,
|
||||
"column": 16
|
||||
}
|
||||
]
|
||||
@@ -4436,7 +4496,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 243,
|
||||
"line": 248,
|
||||
"column": 21
|
||||
}
|
||||
]
|
||||
@@ -4510,7 +4570,7 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 186,
|
||||
"line": 191,
|
||||
"column": 21
|
||||
}
|
||||
]
|
||||
@@ -4619,6 +4679,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"fec7210590309465": {
|
||||
"source": "清理全部统计数据",
|
||||
"kind": "text",
|
||||
"placeholders": [],
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 307,
|
||||
"column": 38
|
||||
}
|
||||
]
|
||||
},
|
||||
"ff509c9ba052a21c": {
|
||||
"source": "保存中…",
|
||||
"kind": "text",
|
||||
@@ -4636,7 +4708,7 @@
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 177,
|
||||
"line": 182,
|
||||
"column": 121
|
||||
}
|
||||
]
|
||||
@@ -4660,17 +4732,17 @@
|
||||
"refs": [
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 64,
|
||||
"line": 65,
|
||||
"column": 46
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 198,
|
||||
"line": 203,
|
||||
"column": 22
|
||||
},
|
||||
{
|
||||
"file": "pages/SettingsPage.tsx",
|
||||
"line": 213,
|
||||
"line": 218,
|
||||
"column": 58
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
@@ -48,6 +48,7 @@
|
||||
"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.",
|
||||
@@ -71,6 +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}",
|
||||
"382f2e3419a02fef": "Only clear detailed records",
|
||||
"393e1241552b1870": "Request",
|
||||
"398f8e6c6f0a0b97": "Continue selecting or typing",
|
||||
"39f52eee100131d7": "Cached input",
|
||||
@@ -86,6 +88,7 @@
|
||||
"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",
|
||||
@@ -124,7 +127,6 @@
|
||||
"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?",
|
||||
@@ -144,6 +146,7 @@
|
||||
"65a6318e07ec1e07": "Tools",
|
||||
"65cb9a7b4f620b6b": "Prompt {tokens}",
|
||||
"680680288a6d2ad2": "Show in Dock",
|
||||
"68102220092c1f0f": "Detailed records cleared",
|
||||
"68ad603fafe4e0d6": "Import legacy configuration",
|
||||
"68ea5dd4d7af20e6": "System settings",
|
||||
"6a9906c79f26c0ba": "Start time",
|
||||
@@ -163,7 +166,6 @@
|
||||
"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",
|
||||
@@ -188,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",
|
||||
@@ -231,6 +234,7 @@
|
||||
"b502b1d414664337": "Prompt: {tokens}",
|
||||
"b5141d3d19e9a048": "Yes",
|
||||
"b6725f218ebaef26": "Dock icon shown",
|
||||
"b67e408132f4dbd7": "Clear all statistics?",
|
||||
"b75a46aad3e7c132": "Non-cached input: {tokens}",
|
||||
"b79354009c614ae9": "Statistics",
|
||||
"b86967982067d295": " (estimated)",
|
||||
@@ -259,6 +263,7 @@
|
||||
"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",
|
||||
@@ -326,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"
|
||||
|
||||
@@ -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} 个已存在模型",
|
||||
@@ -48,6 +48,7 @@
|
||||
"217cfe7db1e3d10a": "跟随系统",
|
||||
"22c6b4eb4caee6ae": "代理设置已保存",
|
||||
"22d7895ea5fca72e": "按供应商",
|
||||
"23ae7a90b1b9816d": "清理范围",
|
||||
"23e49479e15e6770": "发现新版本 {version}",
|
||||
"24a0a24864454575": "已存在,跳过",
|
||||
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
|
||||
@@ -71,6 +72,7 @@
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
|
||||
"382f2e3419a02fef": "仅清理详细记录",
|
||||
"393e1241552b1870": "请求",
|
||||
"398f8e6c6f0a0b97": "继续选择或输入",
|
||||
"39f52eee100131d7": "缓存输入",
|
||||
@@ -86,6 +88,7 @@
|
||||
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
|
||||
"3fd118e2ffe0b2b6": "取消全部测试",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"40a08e7cf320ae07": "确定要清理详细记录吗?",
|
||||
"4125fc7ba333524c": "默认亮色",
|
||||
"42655ed8e4108ae2": "输入(非缓存)",
|
||||
"42a1d9e5b037c210": "字节数",
|
||||
@@ -124,7 +127,6 @@
|
||||
"5b17f59d33bde39e": "错误:{error}",
|
||||
"5ba65a74c4e792c5": "按类型",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"5d59857bf039cac9": "Cursor 助手 v{version}",
|
||||
"5f8d556a9c47da3c": "已关闭开机启动",
|
||||
"5f9acfb945229062": "你确认不想再看到此广告吗?",
|
||||
@@ -144,6 +146,7 @@
|
||||
"65a6318e07ec1e07": "工具数",
|
||||
"65cb9a7b4f620b6b": "提示词 {tokens}",
|
||||
"680680288a6d2ad2": "在 Dock 栏显示",
|
||||
"68102220092c1f0f": "详细记录已清理",
|
||||
"68ad603fafe4e0d6": "导入旧版配置",
|
||||
"68ea5dd4d7af20e6": "系统设置",
|
||||
"6a9906c79f26c0ba": "开始时间",
|
||||
@@ -163,7 +166,6 @@
|
||||
"7cea2f3c46565d29": "OpenAI 额外参数",
|
||||
"7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装",
|
||||
"7e0891860c9e6374": "TAB 服务地址不能为空",
|
||||
"7e1f06318e80c3af": "统计数据已清理",
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
@@ -188,6 +190,7 @@
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
"919cb0ce0c8db4e7": "留空表示保留当前密码",
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"940a168911ade998": "每页条数",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
@@ -231,6 +234,7 @@
|
||||
"b502b1d414664337": "提示词:{tokens}",
|
||||
"b5141d3d19e9a048": "是",
|
||||
"b6725f218ebaef26": "已显示 Dock 栏图标",
|
||||
"b67e408132f4dbd7": "确定要清理全部统计数据吗?",
|
||||
"b75a46aad3e7c132": "非缓存输入:{tokens}",
|
||||
"b79354009c614ae9": "统计数据",
|
||||
"b86967982067d295": "(估算)",
|
||||
@@ -259,6 +263,7 @@
|
||||
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
|
||||
"d0bfccc77315d887": "近一个月",
|
||||
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
|
||||
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"d2d648bd1c94b7f9": "认证",
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
@@ -326,6 +331,7 @@
|
||||
"fdc4cabc370fa3f7": "暂无选项",
|
||||
"fea405f9b01d1416": "概览",
|
||||
"fec45092945f8790": "使用教程",
|
||||
"fec7210590309465": "清理全部统计数据",
|
||||
"ff509c9ba052a21c": "保存中…",
|
||||
"ff66b1c7e010bbc2": "请在终端中粘贴授权命令并输入密码,完成后点击下方按钮",
|
||||
"ff673195f05fb056": "服务端口"
|
||||
|
||||
@@ -3,6 +3,7 @@ import appIcon from "../../src-tauri/icons/32x32.png";
|
||||
import { currentAppVersion } from "../native/appLifecycle";
|
||||
import type { DesktopPlatform } from "../native/platform";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { MacTrafficLights } from "./MacTrafficLights";
|
||||
import styles from "./AppHeader.module.scss";
|
||||
|
||||
type AppHeaderProps = {
|
||||
@@ -11,6 +12,7 @@ type AppHeaderProps = {
|
||||
};
|
||||
|
||||
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||
const showMacTrafficLights = !nativeDesktop && platform === "macos";
|
||||
const showNativeUi = nativeDesktop && platform !== "macos";
|
||||
const [version, setVersion] = useState("…");
|
||||
|
||||
@@ -25,6 +27,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
|
||||
return <header className={styles.root}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div className={styles.uiLayer}>
|
||||
{showMacTrafficLights && <MacTrafficLights />}
|
||||
{showNativeUi && <>
|
||||
<div className={styles.identity} aria-label="Cursor BYOK">
|
||||
<img src={appIcon} alt="" />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
border: 0.5px solid rgb(0 0 0 / 18%);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 0.5px rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.close {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.minimize {
|
||||
background: #febc2e;
|
||||
}
|
||||
|
||||
.zoom {
|
||||
background: #28c840;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import styles from "./MacTrafficLights.module.scss";
|
||||
|
||||
export function MacTrafficLights() {
|
||||
return <div className={styles.root} aria-hidden="true">
|
||||
<span className={[styles.light, styles.close].join(" ")} />
|
||||
<span className={[styles.light, styles.minimize].join(" ")} />
|
||||
<span className={[styles.light, styles.zoom].join(" ")} />
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "plugins/**/*.ts"]
|
||||
"include": ["vite*.config.ts", "plugins/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.ts";
|
||||
|
||||
const desktopRoot = fileURLToPath(new URL("./", import.meta.url));
|
||||
const demoInput = fileURLToPath(new URL("./demo/index.html", import.meta.url));
|
||||
const demoOutput = fileURLToPath(new URL("../docs/public/product-demo", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: desktopRoot,
|
||||
base: "/product-demo/",
|
||||
plugins: [staticI18nPlugin(), react()],
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir: demoOutput,
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: demoInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,48 @@
|
||||
# cursor-byok 文档站
|
||||
|
||||
基于 [Fumadocs](https://fumadocs.dev) 和 Next.js 的独立内容应用。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
apps/docs/
|
||||
├── app/
|
||||
│ ├── (home)/ # Hero 首页与开发者博客
|
||||
│ └── docs/ # 用户文档布局和页面
|
||||
├── components/ # MDX 组件
|
||||
├── content/
|
||||
│ ├── docs/ # 用户文档
|
||||
│ └── blog/ # 开发者博客
|
||||
└── lib/ # 内容源、站点信息与布局配置
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
先安装依赖:
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs install
|
||||
```
|
||||
|
||||
从仓库根目录启动:
|
||||
|
||||
```bash
|
||||
make dev-docs
|
||||
```
|
||||
|
||||
浏览器打开 <http://localhost:3000>。
|
||||
|
||||
## 修改内容
|
||||
|
||||
- 在 `content/docs` 中维护面向使用者的 `.mdx` 文档。
|
||||
- 在 `content/docs/meta.json` 中维护文档侧边栏标题与页面顺序。
|
||||
- 在 `content/blog` 中维护开发者文章,文件名以 `YYYY-MM-DD-` 开头用于排序和展示日期。
|
||||
- 在 `lib/shared.ts` 中维护站点名称、仓库和发布地址。
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npm --prefix apps/docs run check
|
||||
```
|
||||
|
||||
生产构建输出由 Next.js 管理。部署环境可通过 `NEXT_PUBLIC_SITE_URL` 设置站点公开地址;未设置时使用 `https://docs.leokun.cn`。
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,10 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) 聊聊。
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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 和模型名称,并运行连通性测试,测试通过即可。
|
||||
|
||||

|
||||
|
||||
不同模型如何选择类型与协议,见[模型配置](./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.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: 安装指南
|
||||
description: 下载 Cursor Byok,完成本地初始化并验证运行状态。
|
||||
icon: Download
|
||||
---
|
||||
|
||||
## 下载应用
|
||||
|
||||
前往 [最新版本页面](https://github.com/leookun/cursor-byok/releases/latest),下载适用于 macOS、Windows 或 Linux 的安装包。
|
||||
|
||||
<Callout type="info">
|
||||
优先使用最新正式版本。发行页面会列出该版本包含的安装包和更新说明。
|
||||
</Callout>
|
||||
|
||||
## 首次启动
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### 打开 Cursor 配置
|
||||
|
||||
启动应用后进入 **Cursor 配置**。如果本地 CA 尚未初始化,页面会显示初始化入口。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 初始化本地 CA
|
||||
|
||||
点击 **初始化 CA**。本地 CA 用于在你的设备上解析 Cursor 发出的 HTTPS 请求,其文件只保存在本机。
|
||||
|
||||
系统要求授权时,按照应用显示的说明在终端中完成信任操作,然后返回应用点击 **我已初始化,刷新**。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 添加第一个模型
|
||||
|
||||
点击 **添加模型**,选择 OpenAI 或 Anthropic 类型,填写上游服务参数并保存。详细字段说明见[模型配置](./model-configuration.mdx)。
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### 运行连通性测试
|
||||
|
||||
点击模型的 **测试**。测试成功后,该模型即可出现在 Cursor 的模型列表中。
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 验证安装
|
||||
|
||||
完成配置后,请确认:
|
||||
|
||||
- Cursor 配置页面不再显示 CA 初始化提示。
|
||||
- 至少一个模型的连通性测试成功。
|
||||
- Cursor Byok 保持运行。
|
||||
- Cursor 模型列表中能看到配置的显示名称。
|
||||
|
||||
如果其中任何一步失败,请前往[故障排查](./troubleshooting.mdx)。
|
||||
|
||||
## 更新
|
||||
|
||||
在应用设置中检查新版本,或直接访问 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest)。更新前无需删除现有模型配置。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "User Guide",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "troubleshooting"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
---
|
||||
|
||||
每个模型配置都是一个独立的上游通道,可以使用不同服务商、协议、凭据和生成参数。
|
||||
|
||||

|
||||
|
||||
## 如何选择类型与协议
|
||||
|
||||
按上游模型系列选择,可以避免绝大多数兼容性问题:
|
||||
|
||||
| 模型系列 | 模型类型 | 请求协议 |
|
||||
| --- | --- | --- |
|
||||
| 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 使用该模型。
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: TAB 服务
|
||||
description: 选择 Cursor Tab 补全接口的连接方式。
|
||||
icon: Zap
|
||||
---
|
||||
|
||||
Cursor 的 Tab 补全不走模型通道,而是由单独的 Tab 服务处理。Cursor Byok 在 **系统设置 → TAB 设置** 中提供三种连接方式。
|
||||
|
||||

|
||||
|
||||
## 三种模式
|
||||
|
||||
### 使用公益服务(默认)
|
||||
|
||||
使用由项目作者部署的公共 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 或其他凭据。
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cn } from 'cnfast';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { gitConfig } from './shared';
|
||||
|
||||
export type RepoStats = {
|
||||
stars: number | null;
|
||||
version: string | null;
|
||||
};
|
||||
|
||||
async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
// 构建期取一次并静态化;数据随每次部署更新,运行时无需 ISR 缓存设施
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
cache: 'force-cache',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoStats(): Promise<RepoStats> {
|
||||
const base = `https://api.github.com/repos/${gitConfig.user}/${gitConfig.repo}`;
|
||||
const [repo, release] = await Promise.all([
|
||||
fetchJson(base),
|
||||
fetchJson(`${base}/releases/latest`),
|
||||
]);
|
||||
|
||||
return {
|
||||
stars: typeof repo?.stargazers_count === 'number' ? repo.stargazers_count : null,
|
||||
version: typeof release?.tag_name === 'string' ? release.tag_name : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatStars(stars: number): string {
|
||||
return new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(stars);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineI18n } from 'fumadocs-core/i18n';
|
||||
|
||||
export const i18n = defineI18n({
|
||||
defaultLanguage: 'zh',
|
||||
languages: ['zh', 'en'],
|
||||
// 所有语言显式带前缀;未带前缀的访问由中间件按浏览器 Accept-Language 协商后重定向
|
||||
hideLocale: 'never',
|
||||
});
|
||||
|
||||
export type Language = (typeof i18n)['languages'][number];
|
||||
|
||||
export function isLanguage(value: string): value is Language {
|
||||
return i18n.languages.some((lang) => lang === value);
|
||||
}
|
||||
|
||||
/** HTML `lang` attribute values. */
|
||||
export const htmlLang: Record<Language, string> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en',
|
||||
};
|
||||
|
||||
/** Locale ids understood by the desktop app / demo. */
|
||||
export const demoLocale: Record<Language, string> = {
|
||||
zh: 'zh-CN',
|
||||
en: 'en-US',
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import Image from 'next/image';
|
||||
import { zhCN } from '@fumadocs/language/zh-cn';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { uiTranslations } from 'fumadocs-ui/i18n';
|
||||
import { i18n, type Language } from './i18n';
|
||||
import { appName, gitConfig, releaseUrl } from './shared';
|
||||
|
||||
export const translations = i18n.translations().extend(uiTranslations()).preset('zh', zhCN());
|
||||
|
||||
const navLabels: Record<Language, { docs: string; blog: string; download: string }> = {
|
||||
zh: { docs: '文档', blog: '开发者博客', download: '下载' },
|
||||
en: { docs: 'Docs', blog: 'Blog', download: 'Download' },
|
||||
};
|
||||
|
||||
export function baseOptions(lang: Language): BaseLayoutProps {
|
||||
const labels = navLabels[lang];
|
||||
const prefix = `/${lang}`;
|
||||
|
||||
return {
|
||||
nav: {
|
||||
title: (
|
||||
<>
|
||||
<Image src="/images/logo.png" alt="" width={20} height={20} className="rounded-[5px]" />
|
||||
{appName}
|
||||
</>
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: labels.docs,
|
||||
url: `${prefix}/docs`,
|
||||
},
|
||||
{
|
||||
text: labels.blog,
|
||||
url: `${prefix}/blog`,
|
||||
},
|
||||
{
|
||||
text: labels.download,
|
||||
url: releaseUrl,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const appName = 'Cursor Byok';
|
||||
export const appDescription = 'Cursor 服务端的开源替代';
|
||||
export const docsRoute = '/docs';
|
||||
export const docsImageRoute = '/og/docs';
|
||||
export const docsContentRoute = '/llms.mdx/docs';
|
||||
export const repositoryUrl = 'https://github.com/leookun/cursor-byok';
|
||||
export const releaseUrl = `${repositoryUrl}/releases/latest`;
|
||||
|
||||
export const gitConfig = {
|
||||
user: 'leookun',
|
||||
repo: 'cursor-byok',
|
||||
branch: 'main',
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
|
||||
import { defineDocs } from 'fumadocs-mdx/macro';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { i18n } from './i18n';
|
||||
|
||||
const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
docs: {
|
||||
schema: pageSchema,
|
||||
postprocess: {
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: docsRoute,
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
i18n,
|
||||
});
|
||||
|
||||
/** Locale URL prefix segment; all locales are explicit (hideLocale: 'never'). */
|
||||
function localeSegment(page: (typeof source)['$inferPage']): string | undefined {
|
||||
return page.locale;
|
||||
}
|
||||
|
||||
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
|
||||
const segments = [...page.slugs, 'image.png'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url:
|
||||
'/' +
|
||||
[localeSegment(page), ...docsImageRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
|
||||
const segments = [...page.slugs, 'content.md'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url:
|
||||
'/' +
|
||||
[localeSegment(page), ...docsContentRoute.split('/'), ...segments].filter(Boolean).join('/'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: (typeof source)['$inferPage']) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
// 站内图片均为已按展示尺寸截取的静态资源,直接由 CDN 提供,
|
||||
// 避免在 Cloudflare Workers 上依赖运行时图片优化服务。
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||
|
||||
// 站点全部页面在构建期静态生成,无需增量缓存 / 队列等运行时缓存设施。
|
||||
export default defineCloudflareConfig({});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "cursor-byok-docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:product-demo": "node scripts/build-product-demo.mjs",
|
||||
"prebuild": "npm run build:product-demo",
|
||||
"build": "next build",
|
||||
"predev": "npm run build:product-demo",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"types:check": "next typegen && tsc --noEmit",
|
||||
"lint": "eslint",
|
||||
"check": "npm run types:check && npm run lint && npm run build",
|
||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumadocs/language": "^0.2.4",
|
||||
"@opennextjs/cloudflare": "^1.20.4",
|
||||
"cnfast": "^0.1.0",
|
||||
"fumadocs-core": "16.15.2",
|
||||
"fumadocs-mdx": "15.3.1",
|
||||
"fumadocs-ui": "npm:@fumadocs/base-ui@16.15.2",
|
||||
"lucide-react": "^1.34.0",
|
||||
"next": "16.3",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.3",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"wrangler": "^4.127.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server';
|
||||
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';
|
||||
import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware';
|
||||
import { i18n, type Language } from '@/lib/i18n';
|
||||
import { docsContentRoute, docsRoute } from '@/lib/shared';
|
||||
|
||||
const { rewrite: rewriteDocs } = rewritePath(
|
||||
`${docsRoute}{/*path}`,
|
||||
`${docsContentRoute}{/*path}/content.md`,
|
||||
);
|
||||
const { rewrite: rewriteSuffix } = rewritePath(
|
||||
`${docsRoute}{/*path}.md`,
|
||||
`${docsContentRoute}{/*path}/content.md`,
|
||||
);
|
||||
|
||||
const i18nMiddleware = createI18nMiddleware(i18n);
|
||||
|
||||
/** Split the locale prefix (if any) from a pathname. */
|
||||
function splitLocale(pathname: string): { locale: Language; rest: string } {
|
||||
for (const lang of i18n.languages) {
|
||||
if (pathname === `/${lang}`) return { locale: lang, rest: '/' };
|
||||
if (pathname.startsWith(`/${lang}/`)) {
|
||||
return { locale: lang, rest: pathname.slice(lang.length + 1) };
|
||||
}
|
||||
}
|
||||
return { locale: i18n.defaultLanguage, rest: pathname };
|
||||
}
|
||||
|
||||
export default function proxy(request: NextRequest, event: NextFetchEvent) {
|
||||
const { locale, rest } = splitLocale(request.nextUrl.pathname);
|
||||
|
||||
const suffixTarget = rewriteSuffix(rest);
|
||||
if (suffixTarget) {
|
||||
return NextResponse.rewrite(new URL(`/${locale}${suffixTarget}`, request.nextUrl));
|
||||
}
|
||||
|
||||
if (isMarkdownPreferred(request)) {
|
||||
const target = rewriteDocs(rest);
|
||||
|
||||
if (target) {
|
||||
return NextResponse.rewrite(new URL(`/${locale}${target}`, request.nextUrl), {
|
||||
// this URL has two representations, selected by `Accept`
|
||||
headers: { Vary: 'Accept' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return i18nMiddleware(request, event);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|favicon.ico|icon.png|images/|product-demo/|llms\\.txt|llms-full\\.txt).*)'],
|
||||
};
|
||||
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,18 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const docsRoot = path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
|
||||
const desktopRoot = path.resolve(docsRoot, '../desktop');
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const vite = path.join(desktopRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'vite.cmd' : 'vite');
|
||||
|
||||
if (!existsSync(vite)) run(['ci'], desktopRoot);
|
||||
run(['run', 'build:demo'], desktopRoot);
|
||||
|
||||
function run(args, cwd) {
|
||||
const result = spawnSync(npm, args, { cwd, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "cursor-byok-docs",
|
||||
"main": ".open-next/worker.js",
|
||||
"compatibility_date": "2025-09-01",
|
||||
"compatibility_flags": [
|
||||
// 启用 Node.js API,OpenNext 运行时需要
|
||||
"nodejs_compat",
|
||||
// 只允许 fetch 公网地址
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
"service": "cursor-byok-docs"
|
||||
}
|
||||
]
|
||||
// 绑定自定义域名(要求 leokun.cn 的 DNS 托管在同一 Cloudflare 账号)时取消注释:
|
||||
// "routes": [{ "pattern": "docs.leokun.cn", "custom_domain": true }]
|
||||
}
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 244 KiB |
@@ -0,0 +1,35 @@
|
||||
## Problem
|
||||
|
||||
During normal Agent usage, the flow silently stops mid-conversation. The AI says "now I'll read the files" and then never continues. No tool call, no more text, no error. It just freezes.
|
||||
|
||||
## Root Causes
|
||||
|
||||
### 1. Actor not emitting end-stream on channel close (actor.rs)
|
||||
When the command channel closes or receives Abort, the actor only called handle.cancel() which sets the cancellation token but never emits an end-stream frame. The SSE stream hangs indefinitely.
|
||||
|
||||
### 2. lifecycle encoding failure skips close_output() (lifecycle.rs)
|
||||
Both fail() and cancel() used the ? operator on encode_error_end_stream(), meaning if serialization fails, close_output() is never called. The stream stays open silently.
|
||||
|
||||
### 3. Context/blob sync timeouts too aggressive (context_sync.rs, blob_sync.rs)
|
||||
Hardcoded 15-second timeouts kill sessions silently on slow networks or with proxy configurations.
|
||||
|
||||
### 4. Bash tool not recognized (dispatch/mod.rs)
|
||||
Cursor IDE sends "Bash" tool calls but the server only recognized "Shell", causing "unsupported tool: Bash" errors.
|
||||
|
||||
## Changes
|
||||
|
||||
- actor.rs: Use lifecycle::cancel() instead of handle.cancel()
|
||||
- lifecycle.rs: Use match instead of ? with fallback end-stream frame
|
||||
- sessions.rs: Document correct Notify pattern
|
||||
- context_sync.rs: 15s to 60s timeout
|
||||
- blob_sync.rs: 15s to 60s timeout (SET and GET)
|
||||
- dispatch/mod.rs: Add Bash tool alias for Shell
|
||||
- openai_chat.rs: Add detailed debug logging
|
||||
- router.rs: Add detailed debug logging
|
||||
|
||||
## Testing
|
||||
|
||||
- All existing tests pass
|
||||
- cargo check compiles cleanly
|
||||
- Both debug and release builds succeed
|
||||
- Tested with Cursor BYOK desktop app
|
||||
@@ -2,7 +2,7 @@
|
||||
"tools": [
|
||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||
"EditNotebook", "TodoWrite", "StrReplace", "Write", "Read", "ReadLints",
|
||||
"Glob", "AskQuestion", "Task", "AwaitShell", "GetMcpTools",
|
||||
"Glob", "AskQuestion", "Task", "GetMcpTools",
|
||||
"FetchMcpResource", "SwitchMode", "CallMcpTool", "SembleSearch",
|
||||
"SembleFindRelated"
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"tools": [
|
||||
"Shell", "Grep", "Delete", "WebSearch", "WebFetch", "GenerateImage",
|
||||
"ReadLints", "EditNotebook", "TodoWrite", "StrReplace", "Write", "Read",
|
||||
"Glob", "AwaitShell", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
||||
"Glob", "GetMcpTools", "FetchMcpResource", "SwitchMode",
|
||||
"UpdateCurrentStep", "CallMcpTool", "SembleSearch", "SembleFindRelated"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct MessageInsertion {
|
||||
#[derive(Debug)]
|
||||
pub enum ClientCommand {
|
||||
ToolResult(ToolResult),
|
||||
RuntimeMessage(CanonicalMessage),
|
||||
InterruptWithMessage(CanonicalMessage),
|
||||
RuntimeEvent(RuntimeEvent),
|
||||
InsertMessages(MessageInsertion),
|
||||
ClientClosed { error: String },
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::run::RunOutcome;
|
||||
pub enum CommitCause {
|
||||
InitialMessages,
|
||||
ToolRoundStarted(ToolRoundId),
|
||||
ToolResult { call_id: String },
|
||||
ToolResult { call_id: String, interrupted: bool },
|
||||
FinalTurn,
|
||||
Compaction { summary: String },
|
||||
RuntimeEvent { event_id: String },
|
||||
|
||||
@@ -9,6 +9,7 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3";
|
||||
const DATABASE_FILE_NAME: &str = "cursor-byok.db";
|
||||
const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2";
|
||||
const V0049_CONFIG_FILE_NAME: &str = "config.yaml";
|
||||
const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000);
|
||||
|
||||
pub fn managed_data_dir() -> Result<PathBuf> {
|
||||
let home_dir = dirs::home_dir()
|
||||
@@ -70,7 +71,7 @@ impl Config {
|
||||
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
||||
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
||||
})?),
|
||||
Err(env::VarError::NotPresent) => Duration::from_secs(300),
|
||||
Err(env::VarError::NotPresent) => DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
Err(error) => {
|
||||
return Err(Error::Config(format!(
|
||||
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
||||
@@ -111,7 +112,7 @@ impl Config {
|
||||
.parse()
|
||||
.expect("desktop listen address is static"),
|
||||
database_url: default_database_url()?,
|
||||
provider_request_timeout: Duration::from_secs(300),
|
||||
provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT,
|
||||
console: None,
|
||||
use_persisted_ports: true,
|
||||
})
|
||||
|
||||
@@ -565,6 +565,10 @@ impl ControlService {
|
||||
self.store.clear_statistics_storage().await
|
||||
}
|
||||
|
||||
pub async fn clear_all_statistics_storage(&self) -> Result<StatisticsStorage> {
|
||||
self.store.clear_all_statistics_storage().await
|
||||
}
|
||||
|
||||
pub async fn proxy_settings(&self) -> Result<ProxySettings> {
|
||||
self.store.proxy_settings().await
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::Result;
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::store::{
|
||||
DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage,
|
||||
TabSettings,
|
||||
StatisticsStorageScope, TabSettings,
|
||||
};
|
||||
|
||||
use super::{ControlService, ObservabilitySettings};
|
||||
@@ -36,8 +37,20 @@ pub async fn get_storage(State(service): State<ControlService>) -> Result<Json<S
|
||||
|
||||
pub async fn clear_storage(
|
||||
State(service): State<ControlService>,
|
||||
input: Option<Json<ClearStorageInput>>,
|
||||
) -> Result<Json<StatisticsStorage>> {
|
||||
Ok(Json(service.clear_statistics_storage().await?))
|
||||
let scope = input.map(|Json(input)| input.scope).unwrap_or_default();
|
||||
let storage = match scope {
|
||||
StatisticsStorageScope::Details => service.clear_statistics_storage().await?,
|
||||
StatisticsStorageScope::All => service.clear_all_statistics_storage().await?,
|
||||
};
|
||||
Ok(Json(storage))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClearStorageInput {
|
||||
#[serde(default)]
|
||||
pub scope: StatisticsStorageScope,
|
||||
}
|
||||
|
||||
pub async fn get_proxy(State(service): State<ControlService>) -> Result<Json<ProxySettings>> {
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
store::Store,
|
||||
};
|
||||
|
||||
use super::{inbox::OrderedInbox, CursorCommand, CursorSessionHandle};
|
||||
use super::{inbox::OrderedInbox, lifecycle, CursorCommand, CursorSessionHandle};
|
||||
|
||||
pub struct CursorActor;
|
||||
|
||||
@@ -58,13 +58,14 @@ impl CursorActor {
|
||||
let command = match receiver.recv().await {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.cancel();
|
||||
lifecycle::cancel(&handle).ok();
|
||||
break;
|
||||
}
|
||||
};
|
||||
match command {
|
||||
CursorCommand::Abort => {
|
||||
handle.cancel();
|
||||
handle.mark_conversation_cancelled();
|
||||
lifecycle::cancel(&handle).ok();
|
||||
}
|
||||
CursorCommand::Finished => {
|
||||
break;
|
||||
@@ -76,6 +77,24 @@ impl CursorActor {
|
||||
Some(pb::agent_client_message::Message::RunRequest(
|
||||
request,
|
||||
)) => {
|
||||
if let Some(conversation_id) =
|
||||
request.conversation_id.as_deref()
|
||||
{
|
||||
if let Err(error) =
|
||||
handle.set_conversation_id(conversation_id)
|
||||
{
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"invalid Cursor conversation id"
|
||||
);
|
||||
let _ =
|
||||
crate::cursor::lifecycle::fail(&handle, &error);
|
||||
let _ =
|
||||
handle.command(CursorCommand::Finished).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some((results, runtime_actions, dependencies)) =
|
||||
run_resources.take()
|
||||
{
|
||||
@@ -285,6 +304,10 @@ impl CursorActor {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tool_runtime.is_interrupted(throw.id).await {
|
||||
tool_runtime.discard_exec(throw.id).await;
|
||||
continue;
|
||||
}
|
||||
match tool_runtime.take_exec(throw.id).await {
|
||||
Some(pending) => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
@@ -353,8 +376,12 @@ impl CursorActor {
|
||||
) => match action.action {
|
||||
Some(
|
||||
pb::conversation_action::Action::UserMessageAction(_),
|
||||
)
|
||||
| Some(pb::conversation_action::Action::CancelAction(_)) => {
|
||||
) => {
|
||||
handle.mark_conversation_cancelled();
|
||||
handle.cancel();
|
||||
}
|
||||
Some(pb::conversation_action::Action::CancelAction(_)) => {
|
||||
handle.mark_conversation_cancelled();
|
||||
handle.cancel();
|
||||
}
|
||||
Some(
|
||||
|
||||
@@ -43,6 +43,33 @@ impl DecodedAppend {
|
||||
request.conversation_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn is_background_task_completion(&self) -> bool {
|
||||
let Some(agent::agent_client_message::Message::RunRequest(request)) =
|
||||
self.message.message.as_ref()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
request
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|action| action.action.as_ref()),
|
||||
Some(agent::conversation_action::Action::BackgroundTaskCompletionAction(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn is_runtime_cancellation(&self) -> bool {
|
||||
matches!(
|
||||
self.message.message.as_ref(),
|
||||
Some(agent::agent_client_message::Message::ConversationAction(action))
|
||||
if matches!(
|
||||
action.action.as_ref(),
|
||||
Some(agent::conversation_action::Action::CancelAction(_))
|
||||
| Some(agent::conversation_action::Action::UserMessageAction(_))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trace_metadata(&self) -> serde_json::Value {
|
||||
let Some(message) = self.message.message.as_ref() else {
|
||||
return serde_json::json!({
|
||||
@@ -163,7 +190,29 @@ pub async fn append(
|
||||
request: DecodedAppend,
|
||||
parent: Option<CursorParent>,
|
||||
) -> Result<ai::BidiAppendResponse> {
|
||||
if let Some(conversation_id) = request.conversation_id() {
|
||||
if request.is_background_task_completion()
|
||||
&& registry.conversation_cancelled(conversation_id)
|
||||
{
|
||||
tracing::info!(
|
||||
request_id = %request.request_id,
|
||||
%conversation_id,
|
||||
"dropping background task completion for cancelled conversation"
|
||||
);
|
||||
return Ok(ai::BidiAppendResponse {});
|
||||
}
|
||||
if !request.is_background_task_completion() {
|
||||
registry.clear_conversation_cancelled(conversation_id);
|
||||
}
|
||||
}
|
||||
let handle = registry.get_or_create(&request.request_id).await?;
|
||||
if let Some(conversation_id) = request.conversation_id() {
|
||||
handle.set_conversation_id(conversation_id)?;
|
||||
}
|
||||
if request.is_runtime_cancellation() {
|
||||
handle.mark_conversation_cancelled();
|
||||
handle.cancel();
|
||||
}
|
||||
if let Some(parent) = parent {
|
||||
handle.set_parent(parent)?;
|
||||
}
|
||||
@@ -225,4 +274,21 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(decoded.model_id(), Some("grok-4.6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_background_task_completion_actions() {
|
||||
let decoded = decode(&encoded(agent::AgentRunRequest {
|
||||
action: Some(agent::ConversationAction {
|
||||
action: Some(
|
||||
agent::conversation_action::Action::BackgroundTaskCompletionAction(
|
||||
agent::BackgroundTaskCompletionAction::default(),
|
||||
),
|
||||
),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(decoded.is_background_task_completion());
|
||||
}
|
||||
}
|
||||
|
||||