mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 22:27:15 +08:00
feat: 重构项目为标准 Go 结构
- 重构为标准 Go 项目结构 (cmd/server, internal/) - 配置改为 YAML 格式 - 添加 Anthropic Messages API 支持 - 添加 OpenAI Chat API 支持 - 浏览器自动化处理人机验证 - 添加详细中文注释 - 添加免责声明
This commit is contained in:
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
9
.idea/cursor2api.iml
generated
Normal file
9
.idea/cursor2api.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="github.com/pkg/errors" />
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/cursor2api.iml" filepath="$PROJECT_DIR$/.idea/cursor2api.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
140
README.md
Normal file
140
README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Cursor2API
|
||||
|
||||
将 Cursor API 转换为 OpenAI/Anthropic 兼容格式的代理服务。
|
||||
|
||||
## 原理
|
||||
|
||||
本项目利用 [Cursor 文档页面](https://cursor.com/cn/docs) 提供的免费 AI 聊天功能。该页面内置了一个 AI 助手,通过 `https://cursor.com/api/chat` 接口与后端通信。
|
||||
|
||||
**关键特点:**
|
||||
- **无需登录** - 文档页面的 AI 聊天功能对所有访问者开放
|
||||
- **无需 API Key** - 不需要 Cursor 账号或付费订阅
|
||||
- **支持多模型** - 可使用 Claude、GPT、Gemini 等模型
|
||||
|
||||
本项目通过浏览器自动化技术访问该页面,将请求转发到 Cursor API,并将响应转换为标准的 OpenAI/Anthropic API 格式。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Anthropic Messages API** - 完整支持 `/v1/messages` 接口
|
||||
- **OpenAI Chat API** - 支持 `/v1/chat/completions` 接口
|
||||
- **流式响应** - 支持 SSE 流式输出
|
||||
- **浏览器自动化** - 自动处理人机验证
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
cursor2api/
|
||||
├── cmd/server/ # 程序入口
|
||||
│ └── main.go
|
||||
├── internal/ # 内部包
|
||||
│ ├── browser/ # 浏览器自动化服务
|
||||
│ ├── config/ # 配置管理
|
||||
│ └── handler/ # HTTP 处理器
|
||||
├── static/ # 静态文件
|
||||
├── config.yaml # 配置文件
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod tidy
|
||||
|
||||
# 编译
|
||||
go build -o cursor2api ./cmd/server
|
||||
|
||||
# 运行
|
||||
./cursor2api
|
||||
```
|
||||
|
||||
服务默认运行在 `http://localhost:3010`
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# 服务端口
|
||||
port: 3010
|
||||
|
||||
# 浏览器设置
|
||||
browser:
|
||||
headless: true
|
||||
path: "/usr/bin/chromium"
|
||||
```
|
||||
|
||||
支持环境变量 `PORT` 覆盖端口配置。
|
||||
|
||||
## API 接口
|
||||
|
||||
### Anthropic Messages API
|
||||
|
||||
```bash
|
||||
curl http://localhost:3010/v1/messages \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: any" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### OpenAI Chat API
|
||||
|
||||
```bash
|
||||
curl http://localhost:3010/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 其他接口
|
||||
|
||||
- `GET /v1/models` - 获取模型列表
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /browser/status` - 浏览器状态
|
||||
|
||||
## Claude Code 集成
|
||||
|
||||
```bash
|
||||
# 设置 API 地址
|
||||
export ANTHROPIC_BASE_URL=http://localhost:3010
|
||||
|
||||
# 运行 Claude Code
|
||||
claude
|
||||
```
|
||||
|
||||
## 支持的模型
|
||||
|
||||
| 请求模型 | 映射到 Cursor |
|
||||
|---------|--------------|
|
||||
| claude-* | anthropic/claude-sonnet-4.5 |
|
||||
| gpt-* | openai/gpt-5-nano |
|
||||
| gemini-* | google/gemini-2.5-flash |
|
||||
|
||||
## 依赖
|
||||
|
||||
- Go 1.21+
|
||||
- Chromium 浏览器
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供学习和研究目的使用。
|
||||
|
||||
- 本项目是一个非官方的第三方工具,与 Cursor 官方无任何关联
|
||||
- 使用本项目可能违反 Cursor 的服务条款,请自行承担风险
|
||||
- 本项目不提供任何形式的担保,包括但不限于适销性、特定用途适用性
|
||||
- 作者不对使用本项目造成的任何直接或间接损失负责
|
||||
- 请勿将本项目用于商业用途或任何违法活动
|
||||
|
||||
使用本项目即表示您已阅读并同意以上声明。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
64
cmd/server/main.go
Normal file
64
cmd/server/main.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Cursor2API - 将 Cursor API 转换为 OpenAI/Anthropic 兼容格式
|
||||
//
|
||||
// 本项目通过浏览器自动化技术调用 Cursor 的 AI 接口,
|
||||
// 并将其转换为标准的 OpenAI 和 Anthropic API 格式,
|
||||
// 使得各种 AI 客户端可以直接使用 Cursor 的服务。
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"cursor2api/internal/browser"
|
||||
"cursor2api/internal/config"
|
||||
"cursor2api/internal/handler"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg := config.Get()
|
||||
|
||||
// 初始化浏览器服务
|
||||
log.Println("[启动] 正在初始化浏览器服务...")
|
||||
browser.GetService()
|
||||
|
||||
// 创建 Gin 引擎
|
||||
r := gin.Default()
|
||||
|
||||
// ==================== 路由配置 ====================
|
||||
|
||||
// OpenAI 兼容接口
|
||||
r.GET("/v1/models", handler.ListModels)
|
||||
r.POST("/v1/chat/completions", handler.ChatCompletions)
|
||||
|
||||
// Anthropic Messages API 兼容接口
|
||||
r.POST("/v1/messages", handler.Messages)
|
||||
r.POST("/messages", handler.Messages)
|
||||
r.POST("/v1/messages/count_tokens", handler.CountTokens)
|
||||
r.POST("/messages/count_tokens", handler.CountTokens)
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 浏览器状态
|
||||
r.GET("/browser/status", func(c *gin.Context) {
|
||||
svc := browser.GetService()
|
||||
hasToken := svc.GetXIsHuman() != ""
|
||||
c.JSON(200, gin.H{"hasToken": hasToken})
|
||||
})
|
||||
|
||||
// 静态文件
|
||||
r.Static("/static", "./static")
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File("./static/index.html")
|
||||
})
|
||||
|
||||
// 启动服务
|
||||
log.Printf("[启动] 服务运行在端口 %s", cfg.Port)
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("[错误] 启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
12
config.yaml
Normal file
12
config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
# Cursor2API 配置文件
|
||||
# 将 Cursor API 转换为 OpenAI/Anthropic 兼容格式
|
||||
|
||||
# 服务端口
|
||||
port: 3010
|
||||
|
||||
# 浏览器设置
|
||||
browser:
|
||||
# 是否使用无头模式
|
||||
headless: true
|
||||
# Chromium 可执行文件路径
|
||||
path: "/usr/bin/chromium"
|
||||
47
go.mod
Normal file
47
go.mod
Normal file
@@ -0,0 +1,47 @@
|
||||
module cursor2api
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/ysmood/gson v0.7.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
)
|
||||
109
go.sum
Normal file
109
go.sum
Normal file
@@ -0,0 +1,109 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
313
internal/browser/browser.go
Normal file
313
internal/browser/browser.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Package browser 提供基于 Chromium 的浏览器自动化服务
|
||||
// 用于绕过 Cursor API 的人机验证(X-Is-Human token)
|
||||
package browser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cursor2api/internal/config"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/ysmood/gson"
|
||||
)
|
||||
|
||||
// Service 浏览器服务,管理浏览器实例和请求
|
||||
type Service struct {
|
||||
browser *rod.Browser // 浏览器实例
|
||||
page *rod.Page // 当前页面
|
||||
xIsHuman string // X-Is-Human token
|
||||
mu sync.RWMutex // 读写锁
|
||||
lastFetch time.Time // 上次获取 token 时间
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Service
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// GetService 获取浏览器服务单例
|
||||
func GetService() *Service {
|
||||
once.Do(func() {
|
||||
instance = &Service{}
|
||||
instance.init()
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
// init 初始化浏览器实例
|
||||
func (s *Service) init() {
|
||||
cfg := config.Get()
|
||||
|
||||
// 创建临时用户数据目录
|
||||
userDataDir := os.Getenv("BROWSER_USER_DATA_DIR")
|
||||
if userDataDir == "" {
|
||||
userDataDir = fmt.Sprintf("/tmp/cursor2api-browser-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// 配置浏览器启动参数
|
||||
l := launcher.New().
|
||||
Bin(cfg.Browser.Path).
|
||||
Headless(cfg.Browser.Headless).
|
||||
Set("disable-blink-features", "AutomationControlled"). // 隐藏自动化特征
|
||||
Set("no-sandbox").
|
||||
Set("disable-gpu").
|
||||
Set("disable-dev-shm-usage").
|
||||
Set("no-proxy-server"). // 浏览器不使用代理
|
||||
UserDataDir(userDataDir)
|
||||
|
||||
u := l.MustLaunch()
|
||||
|
||||
s.browser = rod.New().ControlURL(u).MustConnect()
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 X-Is-Human token
|
||||
func (s *Service) RefreshToken() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 关闭旧页面
|
||||
if s.page != nil {
|
||||
s.page.Close()
|
||||
}
|
||||
|
||||
s.page = s.browser.MustPage()
|
||||
|
||||
// 设置 User-Agent
|
||||
s.page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
})
|
||||
|
||||
// 隐藏 webdriver 特征
|
||||
s.page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
|
||||
|
||||
// 监听请求,捕获 token
|
||||
var capturedToken string
|
||||
router := s.page.HijackRequests()
|
||||
|
||||
router.MustAdd("*/api/chat*", func(ctx *rod.Hijack) {
|
||||
headers := ctx.Request.Headers()
|
||||
if token, ok := headers["x-is-human"]; ok {
|
||||
capturedToken = token.String()
|
||||
}
|
||||
ctx.ContinueRequest(&proto.FetchContinueRequest{})
|
||||
})
|
||||
|
||||
go router.Run()
|
||||
|
||||
// 访问 Cursor 文档页面
|
||||
if err := s.page.Navigate("https://cursor.com/cn/docs"); err != nil {
|
||||
router.Stop()
|
||||
return fmt.Errorf("导航失败: %w", err)
|
||||
}
|
||||
|
||||
s.page.MustWaitLoad()
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// 尝试触发聊天请求
|
||||
askBtn, err := s.page.Timeout(10 * time.Second).Element(`button:has-text("询问"), button:has-text("Ask"), [data-testid="ask-ai"]`)
|
||||
if err != nil {
|
||||
askBtn, err = s.page.Timeout(5 * time.Second).Element(`textarea, input[type="text"]`)
|
||||
}
|
||||
|
||||
if askBtn != nil {
|
||||
askBtn.Click(proto.InputMouseButtonLeft, 1)
|
||||
time.Sleep(1 * time.Second)
|
||||
askBtn.Input("hi")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
s.page.Keyboard.Press(13)
|
||||
}
|
||||
|
||||
// 等待请求被捕获
|
||||
time.Sleep(8 * time.Second)
|
||||
router.Stop()
|
||||
|
||||
if capturedToken != "" {
|
||||
s.xIsHuman = capturedToken
|
||||
s.lastFetch = time.Now()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetXIsHuman 获取当前 X-Is-Human token
|
||||
func (s *Service) GetXIsHuman() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Token 过期检查(30 分钟刷新)
|
||||
if time.Since(s.lastFetch) > 30*time.Minute && s.xIsHuman != "" {
|
||||
go s.RefreshToken()
|
||||
}
|
||||
|
||||
return s.xIsHuman
|
||||
}
|
||||
|
||||
// CursorChatRequest Cursor API 请求格式
|
||||
type CursorChatRequest struct {
|
||||
Context []CursorContext `json:"context"`
|
||||
Model string `json:"model"`
|
||||
ID string `json:"id"`
|
||||
Messages []CursorMessage `json:"messages"`
|
||||
Trigger string `json:"trigger"`
|
||||
}
|
||||
|
||||
// CursorContext 上下文信息
|
||||
type CursorContext struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
|
||||
// CursorMessage 消息格式
|
||||
type CursorMessage struct {
|
||||
Parts []CursorPart `json:"parts"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// CursorPart 消息内容
|
||||
type CursorPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// SendRequest 发送聊天请求(非流式)
|
||||
func (s *Service) SendRequest(req CursorChatRequest) (string, error) {
|
||||
if s.browser == nil {
|
||||
return "", fmt.Errorf("浏览器未初始化")
|
||||
}
|
||||
|
||||
// 创建新页面
|
||||
page := s.browser.MustPage()
|
||||
defer page.Close()
|
||||
|
||||
// 设置浏览器特征
|
||||
page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
})
|
||||
page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
|
||||
|
||||
// 导航到 Cursor
|
||||
page.MustNavigate("https://cursor.com/cn/docs").MustWaitLoad()
|
||||
|
||||
reqJSON, _ := json.Marshal(req)
|
||||
|
||||
// 使用 JavaScript 发送请求
|
||||
script := fmt.Sprintf(`() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch('https://cursor.com/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(%s)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => reject(new Error(text)));
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(text => resolve(text))
|
||||
.catch(err => reject(err));
|
||||
});
|
||||
}`, string(reqJSON))
|
||||
|
||||
result, err := page.Timeout(90 * time.Second).Evaluate(rod.Eval(script).ByPromise())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("执行失败: %w", err)
|
||||
}
|
||||
|
||||
return result.Value.String(), nil
|
||||
}
|
||||
|
||||
// SendStreamRequest 发送流式聊天请求
|
||||
func (s *Service) SendStreamRequest(req CursorChatRequest, onChunk func(chunk string)) error {
|
||||
if s.browser == nil {
|
||||
return fmt.Errorf("浏览器未初始化")
|
||||
}
|
||||
|
||||
// 创建新页面
|
||||
page := s.browser.MustPage()
|
||||
defer page.Close()
|
||||
|
||||
// 设置浏览器特征
|
||||
page.MustSetUserAgent(&proto.NetworkSetUserAgentOverride{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
})
|
||||
page.MustEvalOnNewDocument(`Object.defineProperty(navigator, 'webdriver', {get: () => false})`)
|
||||
|
||||
// 暴露回调函数给 JavaScript
|
||||
done := make(chan error, 1)
|
||||
page.MustExpose("goStreamCallback", func(j gson.JSON) (interface{}, error) {
|
||||
onChunk(j.String())
|
||||
return "ok", nil
|
||||
})
|
||||
page.MustExpose("goStreamDone", func(j gson.JSON) (interface{}, error) {
|
||||
errMsg := j.String()
|
||||
if errMsg != "" {
|
||||
done <- fmt.Errorf("%s", errMsg)
|
||||
} else {
|
||||
done <- nil
|
||||
}
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
// 导航到 Cursor
|
||||
page.MustNavigate("https://cursor.com/cn/docs").MustWaitLoad()
|
||||
|
||||
reqJSON, _ := json.Marshal(req)
|
||||
|
||||
// 使用 JavaScript 发送流式请求
|
||||
script := fmt.Sprintf(`() => {
|
||||
fetch('https://cursor.com/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(%s)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
window.goStreamDone(text);
|
||||
throw new Error(text);
|
||||
});
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function read() {
|
||||
reader.read().then(({done, value}) => {
|
||||
if (done) {
|
||||
window.goStreamDone("");
|
||||
return;
|
||||
}
|
||||
const chunk = decoder.decode(value, {stream: true});
|
||||
window.goStreamCallback(chunk);
|
||||
read();
|
||||
}).catch(err => {
|
||||
window.goStreamDone(err.message);
|
||||
});
|
||||
}
|
||||
read();
|
||||
})
|
||||
.catch(err => {
|
||||
window.goStreamDone(err.message);
|
||||
});
|
||||
}`, string(reqJSON))
|
||||
|
||||
if _, err := page.Evaluate(rod.Eval(script)); err != nil {
|
||||
return fmt.Errorf("执行失败: %w", err)
|
||||
}
|
||||
|
||||
// 等待流结束
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(90 * time.Second):
|
||||
return fmt.Errorf("请求超时")
|
||||
}
|
||||
}
|
||||
69
internal/config/config.go
Normal file
69
internal/config/config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package config 提供配置文件加载和管理功能
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config 应用配置结构
|
||||
type Config struct {
|
||||
// Port 服务监听端口
|
||||
Port string `yaml:"port"`
|
||||
// Browser 浏览器相关配置
|
||||
Browser BrowserConfig `yaml:"browser"`
|
||||
}
|
||||
|
||||
// BrowserConfig 浏览器配置
|
||||
type BrowserConfig struct {
|
||||
// Headless 是否使用无头模式
|
||||
Headless bool `yaml:"headless"`
|
||||
// Path Chromium 可执行文件路径
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
var (
|
||||
cfg *Config
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Get 获取全局配置实例(单例模式)
|
||||
func Get() *Config {
|
||||
once.Do(func() {
|
||||
cfg = &Config{
|
||||
Port: "3010",
|
||||
Browser: BrowserConfig{
|
||||
Headless: true,
|
||||
Path: "/usr/bin/chromium",
|
||||
},
|
||||
}
|
||||
load(cfg)
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
// load 从配置文件和环境变量加载配置
|
||||
func load(c *Config) {
|
||||
// 尝试读取 YAML 配置文件
|
||||
data, err := os.ReadFile("config.yaml")
|
||||
if err != nil {
|
||||
log.Printf("[配置] 未找到 config.yaml,使用默认配置")
|
||||
} else {
|
||||
if err := yaml.Unmarshal(data, c); err != nil {
|
||||
log.Printf("[配置] 解析 config.yaml 失败: %v", err)
|
||||
} else {
|
||||
log.Printf("[配置] 已加载 config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
// 环境变量覆盖配置文件
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
c.Port = port
|
||||
}
|
||||
|
||||
// 输出最终配置
|
||||
log.Printf("[配置] 端口: %s", c.Port)
|
||||
}
|
||||
323
internal/handler/anthropic.go
Normal file
323
internal/handler/anthropic.go
Normal file
@@ -0,0 +1,323 @@
|
||||
// Package handler 提供 HTTP 请求处理器
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cursor2api/internal/browser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ================== 请求/响应结构体 ==================
|
||||
|
||||
// MessagesRequest Anthropic Messages API 请求格式
|
||||
type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
System interface{} `json:"system,omitempty"` // 可以是 string 或 []ContentBlock
|
||||
}
|
||||
|
||||
// Message 消息格式
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"` // 可以是 string 或 []ContentBlock
|
||||
}
|
||||
|
||||
// MessagesResponse Anthropic Messages API 响应格式
|
||||
type MessagesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence *string `json:"stop_sequence"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// ContentBlock 内容块
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Usage token 使用统计
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// CursorSSEEvent Cursor SSE 事件格式
|
||||
type CursorSSEEvent struct {
|
||||
Type string `json:"type"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
}
|
||||
|
||||
// ================== 辅助函数 ==================
|
||||
|
||||
// generateID 生成唯一标识符
|
||||
func generateID() string {
|
||||
return strings.ReplaceAll(uuid.New().String(), "-", "")[:16]
|
||||
}
|
||||
|
||||
// getTextContent 从 interface{} 提取文本内容
|
||||
// 支持 string 和 []ContentBlock 两种格式
|
||||
func getTextContent(content interface{}) string {
|
||||
if content == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var texts []string
|
||||
for _, item := range v {
|
||||
if block, ok := item.(map[string]interface{}); ok {
|
||||
if block["type"] == "text" {
|
||||
if text, ok := block["text"].(string); ok {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// mapModelName 将模型名称映射到 Cursor 支持的格式
|
||||
func mapModelName(model string) string {
|
||||
model = strings.ToLower(model)
|
||||
|
||||
// 已经是 Cursor 格式
|
||||
if strings.Contains(model, "/") {
|
||||
return model
|
||||
}
|
||||
|
||||
// Claude 模型
|
||||
if strings.Contains(model, "claude") {
|
||||
return "anthropic/claude-sonnet-4.5"
|
||||
}
|
||||
|
||||
// GPT 模型
|
||||
if strings.Contains(model, "gpt") {
|
||||
return "openai/gpt-5-nano"
|
||||
}
|
||||
|
||||
// Gemini 模型
|
||||
if strings.Contains(model, "gemini") {
|
||||
return "google/gemini-2.5-flash"
|
||||
}
|
||||
|
||||
// 默认使用 Claude
|
||||
return "anthropic/claude-sonnet-4.5"
|
||||
}
|
||||
|
||||
// ================== 处理器函数 ==================
|
||||
|
||||
// CountTokens 估算 token 数量
|
||||
func CountTokens(c *gin.Context) {
|
||||
var req MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
// 简单估算:每 4 个字符约 1 个 token
|
||||
totalChars := len(getTextContent(req.System))
|
||||
for _, msg := range req.Messages {
|
||||
totalChars += len(getTextContent(msg.Content))
|
||||
}
|
||||
tokens := totalChars / 4
|
||||
if tokens < 1 {
|
||||
tokens = 1
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"input_tokens": tokens})
|
||||
}
|
||||
|
||||
// Messages 处理 Anthropic Messages API 请求
|
||||
func Messages(c *gin.Context) {
|
||||
var req MessagesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 Cursor 请求格式
|
||||
cursorReq := convertToCursor(req)
|
||||
|
||||
if req.Stream {
|
||||
handleStream(c, cursorReq, req.Model)
|
||||
} else {
|
||||
handleNonStream(c, cursorReq, req.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 请求转换 ==================
|
||||
|
||||
// convertToCursor 将 Anthropic 请求转换为 Cursor 格式
|
||||
func convertToCursor(req MessagesRequest) browser.CursorChatRequest {
|
||||
messages := make([]browser.CursorMessage, 0, len(req.Messages)+1)
|
||||
|
||||
// 添加 system 消息
|
||||
if sysText := getTextContent(req.System); sysText != "" {
|
||||
messages = append(messages, browser.CursorMessage{
|
||||
Parts: []browser.CursorPart{{Type: "text", Text: sysText}},
|
||||
ID: generateID(),
|
||||
Role: "system",
|
||||
})
|
||||
}
|
||||
|
||||
// 添加用户/助手消息
|
||||
for _, msg := range req.Messages {
|
||||
messages = append(messages, browser.CursorMessage{
|
||||
Parts: []browser.CursorPart{{Type: "text", Text: getTextContent(msg.Content)}},
|
||||
ID: generateID(),
|
||||
Role: msg.Role,
|
||||
})
|
||||
}
|
||||
|
||||
return browser.CursorChatRequest{
|
||||
Context: []browser.CursorContext{{
|
||||
Type: "file",
|
||||
Content: "",
|
||||
FilePath: "/docs/",
|
||||
}},
|
||||
Model: mapModelName(req.Model),
|
||||
ID: generateID(),
|
||||
Messages: messages,
|
||||
Trigger: "submit-message",
|
||||
}
|
||||
}
|
||||
|
||||
// ================== API 处理 ==================
|
||||
|
||||
// handleStream 处理流式请求
|
||||
func handleStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
id := "msg_" + generateID()
|
||||
|
||||
// 发送 message_start
|
||||
c.Writer.WriteString("event: message_start\n")
|
||||
c.Writer.WriteString(fmt.Sprintf(`data: {"type":"message_start","message":{"id":"%s","type":"message","role":"assistant","content":[],"model":"%s","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":0}}}`+"\n\n", id, model))
|
||||
flusher.Flush()
|
||||
|
||||
c.Writer.WriteString("event: content_block_start\n")
|
||||
c.Writer.WriteString(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
// 用于累积不完整的 SSE 行
|
||||
var buffer strings.Builder
|
||||
|
||||
svc := browser.GetService()
|
||||
err := svc.SendStreamRequest(cursorReq, func(chunk string) {
|
||||
buffer.WriteString(chunk)
|
||||
content := buffer.String()
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
// 保留最后一个可能不完整的行
|
||||
if !strings.HasSuffix(content, "\n") && len(lines) > 0 {
|
||||
buffer.Reset()
|
||||
buffer.WriteString(lines[len(lines)-1])
|
||||
lines = lines[:len(lines)-1]
|
||||
} else {
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event CursorSSEEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Type == "text-delta" && event.Delta != "" {
|
||||
deltaJSON, _ := json.Marshal(event.Delta)
|
||||
c.Writer.WriteString("event: content_block_delta\n")
|
||||
c.Writer.WriteString(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":` + string(deltaJSON) + `}}` + "\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.Writer.WriteString("event: error\n")
|
||||
c.Writer.WriteString(`data: {"type":"error","error":{"message":"` + err.Error() + `"}}` + "\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
c.Writer.WriteString("event: content_block_stop\n")
|
||||
c.Writer.WriteString(`data: {"type":"content_block_stop","index":0}` + "\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
c.Writer.WriteString("event: message_delta\n")
|
||||
c.Writer.WriteString(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":100}}` + "\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
c.Writer.WriteString("event: message_stop\n")
|
||||
c.Writer.WriteString(`data: {"type":"message_stop"}` + "\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// handleNonStream 处理非流式请求
|
||||
func handleNonStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
|
||||
svc := browser.GetService()
|
||||
result, err := svc.SendRequest(cursorReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var fullText strings.Builder
|
||||
lines := strings.Split(result, "\n")
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event CursorSSEEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Type == "text-delta" && event.Delta != "" {
|
||||
fullText.WriteString(event.Delta)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, MessagesResponse{
|
||||
ID: "msg_" + generateID(),
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ContentBlock{{Type: "text", Text: fullText.String()}},
|
||||
Model: model,
|
||||
StopReason: "end_turn",
|
||||
Usage: Usage{InputTokens: 100, OutputTokens: 100},
|
||||
})
|
||||
}
|
||||
50
internal/handler/models.go
Normal file
50
internal/handler/models.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Package handler 提供 HTTP 请求处理器
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SupportedModels 支持的模型列表
|
||||
var SupportedModels = []string{
|
||||
"anthropic/claude-sonnet-4.5",
|
||||
"openai/gpt-5-nano",
|
||||
"google/gemini-2.5-flash",
|
||||
}
|
||||
|
||||
// Model 模型信息
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
|
||||
// ModelsResponse 模型列表响应
|
||||
type ModelsResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []Model `json:"data"`
|
||||
}
|
||||
|
||||
// ListModels 返回支持的模型列表
|
||||
func ListModels(c *gin.Context) {
|
||||
models := make([]Model, len(SupportedModels))
|
||||
now := time.Now().Unix()
|
||||
|
||||
for i, id := range SupportedModels {
|
||||
models[i] = Model{
|
||||
ID: id,
|
||||
Object: "model",
|
||||
Created: now,
|
||||
OwnedBy: "cursor",
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ModelsResponse{
|
||||
Object: "list",
|
||||
Data: models,
|
||||
})
|
||||
}
|
||||
246
internal/handler/openai.go
Normal file
246
internal/handler/openai.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Package handler 提供 HTTP 请求处理器
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor2api/internal/browser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ================== OpenAI 兼容格式 ==================
|
||||
|
||||
// ChatCompletionRequest OpenAI Chat Completion 请求格式
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []OpenAIMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIMessage OpenAI 消息格式
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ChatCompletionResponse OpenAI Chat Completion 响应格式
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// Choice 选项
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message *OpenAIMessage `json:"message,omitempty"`
|
||||
Delta *OpenAIMessage `json:"delta,omitempty"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// OpenAIUsage token 使用统计
|
||||
type OpenAIUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// ChatCompletionChunk 流式响应块
|
||||
type ChatCompletionChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChunkChoice `json:"choices"`
|
||||
}
|
||||
|
||||
// ChunkChoice 流式选项
|
||||
type ChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta OpenAIMessage `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// ================== 处理器函数 ==================
|
||||
|
||||
// ChatCompletions 处理 OpenAI Chat Completions API 请求
|
||||
func ChatCompletions(c *gin.Context) {
|
||||
var req ChatCompletionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[OpenAI] 请求: model=%s, messages=%d, stream=%v", req.Model, len(req.Messages), req.Stream)
|
||||
|
||||
// 转换为 Cursor 请求格式
|
||||
cursorReq := convertOpenAIToCursor(req)
|
||||
|
||||
if req.Stream {
|
||||
handleOpenAIStream(c, cursorReq, req.Model)
|
||||
} else {
|
||||
handleOpenAINonStream(c, cursorReq, req.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// convertOpenAIToCursor 将 OpenAI 请求转换为 Cursor 格式
|
||||
func convertOpenAIToCursor(req ChatCompletionRequest) browser.CursorChatRequest {
|
||||
messages := make([]browser.CursorMessage, len(req.Messages))
|
||||
for i, msg := range req.Messages {
|
||||
messages[i] = browser.CursorMessage{
|
||||
Parts: []browser.CursorPart{{Type: "text", Text: msg.Content}},
|
||||
ID: generateID(),
|
||||
Role: msg.Role,
|
||||
}
|
||||
}
|
||||
|
||||
return browser.CursorChatRequest{
|
||||
Context: []browser.CursorContext{{
|
||||
Type: "file",
|
||||
Content: "",
|
||||
FilePath: "/docs/",
|
||||
}},
|
||||
Model: mapModelName(req.Model),
|
||||
ID: generateID(),
|
||||
Messages: messages,
|
||||
Trigger: "submit-message",
|
||||
}
|
||||
}
|
||||
|
||||
// handleOpenAIStream 处理 OpenAI 流式请求
|
||||
func handleOpenAIStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
id := "chatcmpl-" + generateID()
|
||||
created := time.Now().Unix()
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
var buffer strings.Builder
|
||||
|
||||
svc := browser.GetService()
|
||||
_ = svc.SendStreamRequest(cursorReq, func(chunk string) {
|
||||
buffer.WriteString(chunk)
|
||||
content := buffer.String()
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
if !strings.HasSuffix(content, "\n") && len(lines) > 0 {
|
||||
buffer.Reset()
|
||||
buffer.WriteString(lines[len(lines)-1])
|
||||
lines = lines[:len(lines)-1]
|
||||
} else {
|
||||
buffer.Reset()
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event CursorSSEEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Type == "text-delta" && event.Delta != "" {
|
||||
chunk := ChatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []ChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: OpenAIMessage{Content: event.Delta},
|
||||
}},
|
||||
}
|
||||
chunkJSON, _ := json.Marshal(chunk)
|
||||
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", chunkJSON)))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 发送结束标记
|
||||
reason := "stop"
|
||||
endChunk := ChatCompletionChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []ChunkChoice{{
|
||||
Index: 0,
|
||||
Delta: OpenAIMessage{},
|
||||
FinishReason: &reason,
|
||||
}},
|
||||
}
|
||||
endJSON, _ := json.Marshal(endChunk)
|
||||
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", endJSON)))
|
||||
c.Writer.Write([]byte("data: [DONE]\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// handleOpenAINonStream 处理 OpenAI 非流式请求
|
||||
func handleOpenAINonStream(c *gin.Context, cursorReq browser.CursorChatRequest, model string) {
|
||||
svc := browser.GetService()
|
||||
result, err := svc.SendRequest(cursorReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var fullContent strings.Builder
|
||||
lines := strings.Split(result, "\n")
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "" || data == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event CursorSSEEvent
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Type == "text-delta" {
|
||||
fullContent.WriteString(event.Delta)
|
||||
}
|
||||
}
|
||||
|
||||
reason := "stop"
|
||||
c.JSON(http.StatusOK, ChatCompletionResponse{
|
||||
ID: "chatcmpl-" + generateID(),
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: model,
|
||||
Choices: []Choice{{
|
||||
Index: 0,
|
||||
Message: &OpenAIMessage{Role: "assistant", Content: fullContent.String()},
|
||||
FinishReason: &reason,
|
||||
}},
|
||||
Usage: &OpenAIUsage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 100,
|
||||
TotalTokens: 200,
|
||||
},
|
||||
})
|
||||
}
|
||||
364
static/index.html
Normal file
364
static/index.html
Normal file
@@ -0,0 +1,364 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cursor2API 测试</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #fef9e7 0%, #fdebd0 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
width: 95%;
|
||||
margin: 30px auto;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(224, 213, 192, 0.5);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.status {
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #e8e8e8;
|
||||
color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.status.ready { background: linear-gradient(135deg, #d4edda, #c3e6cb); color: #155724; }
|
||||
.status.loading { background: linear-gradient(135deg, #fff3cd, #ffeeba); color: #856404; }
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
background: linear-gradient(180deg, #fffdf8 0%, #fffbf0 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e0d5c0;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
overflow-y: auto;
|
||||
padding: 28px;
|
||||
margin-bottom: 24px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
max-width: 80%;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.message:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.message.user {
|
||||
background: linear-gradient(135deg, #f8f3e8 0%, #f5f0e5 100%);
|
||||
margin-left: auto;
|
||||
border: 1px solid #e0d5c0;
|
||||
border-radius: 12px 12px 4px 12px;
|
||||
}
|
||||
.message.assistant {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%);
|
||||
border: 1px solid #e8e0d0;
|
||||
border-radius: 12px 12px 12px 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
.message .role {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.message .content {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: #2c3e50;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.input-area {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e0d5c0;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 180px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
color: #2c3e50;
|
||||
}
|
||||
textarea::placeholder { color: #b0b0b0; }
|
||||
.send-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #f5a623 0%, #e67e22 100%);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 4px 12px rgba(245, 166, 35, 0.35);
|
||||
}
|
||||
.send-btn:hover {
|
||||
transform: scale(1.08) translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(245, 166, 35, 0.45);
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0e8d8;
|
||||
}
|
||||
.model-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #f8f4ec 0%, #f5f1e8 100%);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
border: 1px solid rgba(224, 213, 192, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.model-select:hover {
|
||||
background: linear-gradient(135deg, #f5f1e8 0%, #f0ece2 100%);
|
||||
border-color: rgba(224, 213, 192, 0.8);
|
||||
}
|
||||
.model-select select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #2c3e50;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.agent-badge {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
padding: 6px 14px;
|
||||
border-radius: 15px;
|
||||
font-size: 12px;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(46, 125, 50, 0.15);
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #a0a0a0;
|
||||
padding: 16px 4px;
|
||||
}
|
||||
.footer span {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.typing {
|
||||
display: inline-block;
|
||||
}
|
||||
.typing::after {
|
||||
content: '...';
|
||||
animation: dots 1.5s infinite;
|
||||
}
|
||||
@keyframes dots {
|
||||
0%, 20% { content: '.'; }
|
||||
40% { content: '..'; }
|
||||
60%, 100% { content: '...'; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 Cursor2API 测试</h1>
|
||||
<span id="status" class="status loading">检查中...</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-area" id="chatArea"></div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="input-row">
|
||||
<textarea id="input" placeholder="询问有关文档的问题" rows="1"></textarea>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="19" x2="12" y2="5"></line>
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span class="agent-badge">∞ Agent</span>
|
||||
<div class="model-select">
|
||||
<select id="modelSelect">
|
||||
<option value="anthropic/claude-sonnet-4.5">Sonnet 4.5</option>
|
||||
<option value="openai/gpt-5-nano">GPT-5 Nano</option>
|
||||
<option value="google/gemini-2.5-flash">Gemini 2.5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Tokenizer Off</span>
|
||||
<span id="tokenCount">上下文: 0/200k (0%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chatArea = document.getElementById('chatArea');
|
||||
const input = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const statusEl = document.getElementById('status');
|
||||
const modelSelect = document.getElementById('modelSelect');
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
// 检查服务状态
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
if (res.ok) {
|
||||
statusEl.textContent = '就绪';
|
||||
statusEl.className = 'status ready';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = '离线';
|
||||
statusEl.className = 'status';
|
||||
}
|
||||
}
|
||||
checkStatus();
|
||||
|
||||
function addMessage(role, content) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
div.innerHTML = `
|
||||
<div class="role">${role === 'user' ? '你' : 'AI'}</div>
|
||||
<div class="content">${content}</div>
|
||||
`;
|
||||
chatArea.appendChild(div);
|
||||
chatArea.scrollTop = chatArea.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = input.value.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
sendBtn.disabled = true;
|
||||
input.value = '';
|
||||
|
||||
addMessage('user', text);
|
||||
const assistantMsg = addMessage('assistant', '<span class="typing">思考中</span>');
|
||||
const contentEl = assistantMsg.querySelector('.content');
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: modelSelect.value,
|
||||
messages: [{ role: 'user', content: text }],
|
||||
stream: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
contentEl.textContent = `错误: ${err.error || res.statusText}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json.choices?.[0]?.delta?.content || '';
|
||||
fullContent += delta;
|
||||
contentEl.textContent = fullContent || '...';
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullContent) {
|
||||
contentEl.textContent = '(无响应)';
|
||||
}
|
||||
} catch (e) {
|
||||
contentEl.textContent = `请求失败: ${e.message}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// 自动调整输入框高度
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user