diff --git a/.agents/skills/coding-guidance/SKILL.md b/.agents/skills/coding-guidance/SKILL.md index 9bbc9b1..09dcabd 100644 --- a/.agents/skills/coding-guidance/SKILL.md +++ b/.agents/skills/coding-guidance/SKILL.md @@ -342,3 +342,15 @@ go run ./cmd/cursor-proxy-debugger - `PendingInteraction` - 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending - backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history//state.json` + `history//context.json` + +### 5.1 checkpoint 投影必须幂等且只有一个事实源 + +- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。 +- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。 +- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。 +- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。 +- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。 +- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。 +- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。 +- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。 +- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8ecf8b4..bb1cc33 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,9 +10,3 @@ -## 检查清单 / Checklist - -- [ ] 代码已通过 `gofmt` 和 `go vet` / Code passes `gofmt` and `go vet` -- [ ] 前端构建无报错 / Frontend builds without errors -- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files -- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits \ No newline at end of file diff --git a/.gitignore b/.gitignore index 56f163f..acc7eda 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ server-go/log/ .cursor-local-assistant .cursor-local-assistant-v2 .cursor-app-formatted/ +proto/extensions-cursor-app/ ads-server-linux-amd64.tar cmd/ads-server/*.db cmd/ads-server/*.db-* diff --git a/README-CN.md b/README-CN.md new file mode 100644 index 0000000..37ded74 --- /dev/null +++ b/README-CN.md @@ -0,0 +1,107 @@ +
+ +# cursor-byok + +cursor-byok 是 Cursor 后端的本地实现。 +
+
+leookun/cursor-byok | Trendshift + +[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md) + +[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases) +[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE) +[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest) + +
+ +![cursor-byok 支持接入多种模型 API](./images/cn-brand.png) + +![cursor-byok 主界面](./images/cn-home.png) + +## 项目介绍 + +cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。 + +你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。 + +> [!IMPORTANT] +> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。 + +## 核心能力 + +- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。 +- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。 +- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。 +- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。 +- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。 +- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。 +- **跨平台运行**:支持 macOS、Windows 和 Linux。 + +## 快速开始 + +1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。 +2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。 +3. 测试模型配置;测试通过后返回主界面启动服务。 +4. 打开 Cursor,选择已配置的模型并开始使用 Agent。 + +更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。 + +## 模型管理 + +模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。 + +![cursor-byok 模型配置](./images/cn-model.png) + +## 工作原理 + +```text +Cursor 客户端 + │ + │ Agent 请求与工具结果 + ▼ +cursor-byok 本地服务 + │ + │ OpenAI / Anthropic 兼容请求 + ▼ +你配置的模型 API +``` + +cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。 + +## 为什么做这个项目 + +很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。 + +我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。 + +## 路线图 + +项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。 + +详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。 + +## 社区与支持 + +- [使用教程](https://docs.leokun.cn) +- [GitHub Issues](https://github.com/leookun/cursor-byok/issues) +- [Telegram 交流群](https://t.me/cursor_byok) +- QQ 交流群:`1095916242`、`1094411438`、`1095918002`、`1094419321` + + +## 开发与贡献 + +欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。 + + +## 贡献者名单 + + + + + + +## 许可证 + +本项目基于 [MIT License](./LICENSE) 开源。 diff --git a/README.md b/README.md index 84f0be1..f470265 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,110 @@ -image +
-image +# cursor-byok +cursor-byok is a local implementation of Cursor's backend. +
+
+leookun/cursor-byok | Trendshift -image +[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md) - -## 交流群组 -https://t.me/cursor_byok - - -## 为什么做这个项目 - -公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。 - -我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。 - -这个项目的目标,是让模型选择权重新回到用户手里。 - -## 路线图 - -[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32) -[详细使用教程](https://docs.leokun.cn) - -## 后续 - -后续会继续扩展更多工具和使用场景,包括但不限于: - -- 支持更多 IDE 接入 -- 支持更多 Chat 类应用 -- 支持更多 Agent 工具和工作流 -- 提供更完善的自托管部署方式 -- 持续优化不同模型 API 的兼容性 -- 降低接入成本,让已有模型额度可以被更充分地利用 - -最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。 +[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases) +[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE) +[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest) +
+ +![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png) + +![cursor-byok dashboard](./images/en-home.png) + +## About + +cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP. + +You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform. + +> [!IMPORTANT] +> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers. + +## Features + +- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs. +- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint. +- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations. +- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses. +- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available. +- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value. +- **Cross-platform:** Run on macOS, Windows, and Linux. + +## Quick Start + +1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest). +2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID. +3. Test the model configuration. Once it passes, return to the dashboard and start the service. +4. Open Cursor, select the configured model, and start using Agent. + +For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn). + +## Model Management + +Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters. + +![cursor-byok model settings](./images/en-model.png) + +## How It Works + +```text +Cursor client + │ + │ Agent requests and tool results + ▼ +cursor-byok local service + │ + │ OpenAI- / Anthropic-compatible requests + ▼ +Your model API +``` + +cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure. + +## Why This Project + +Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform. + +cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required. + +## Roadmap + +The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows. + +See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress. + +## Community and Support + +- [User Guide](https://docs.leokun.cn) +- [GitHub Issues](https://github.com/leookun/cursor-byok/issues) +- [Telegram community](https://t.me/cursor_byok) +- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321` + + + +## Development and Contributing + +Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines. + +## Contributors + + + + + + +## License + +This project is open source under the [MIT License](./LICENSE). + + diff --git a/build/Taskfile.yml b/build/Taskfile.yml index e88106d..f3b067c 100644 --- a/build/Taskfile.yml +++ b/build/Taskfile.yml @@ -13,8 +13,8 @@ tasks: preconditions: - sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"' msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。" - - sh: 'test -n "{{.PROTO_INPUT}}" || test -f ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js' - msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。" + - sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js' + msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。" cmds: - chmod +x ./proto/extract_extensions_proto.sh - '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}' @@ -29,9 +29,16 @@ tasks: - cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto - perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto - perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto + - ./proto/check_proto_sync.sh - rm -rf ./gen/agentv1 ./gen/aiserverv1 - task: generate:proto + check:proto: + summary: 检查根 proto 与扩展提取快照是否一致 + dir: '{{.ROOT_DIR}}' + cmds: + - ./proto/check_proto_sync.sh + generate:proto: summary: 生成 proto Go/Connect 代码 dir: '{{.ROOT_DIR}}' diff --git a/build/config.yml b/build/config.yml index 16b98d9..1f0b916 100644 --- a/build/config.yml +++ b/build/config.yml @@ -8,7 +8,7 @@ info: description: "Cursor助手" copyright: "© 2026, Cursor助手" comments: "Cursor助手" - version: "0.0.45" + version: "0.0.47" dev_mode: root_path: . diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 831b455..510cac9 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.45 + 0.0.47 CFBundleVersion - 0.0.45 + 0.0.47 LSMinimumSystemVersion 12.0.0 LSUIElement diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index 56b50c2..b08f9f4 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.45 + 0.0.47 CFBundleVersion - 0.0.45 + 0.0.47 LSMinimumSystemVersion 12.0.0 LSUIElement diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index af16591..8dc6122 100644 --- a/build/linux/nfpm/nfpm.yaml +++ b/build/linux/nfpm/nfpm.yaml @@ -6,7 +6,7 @@ name: "Cursor助手" arch: ${GOARCH} platform: "linux" -version: "0.0.45" +version: "0.0.47" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml index 39fc923..8d98569 100644 --- a/build/windows/Taskfile.yml +++ b/build/windows/Taskfile.yml @@ -1,159 +1,156 @@ -version: "3" - -includes: - common: ../Taskfile.yml - -tasks: - build: - summary: 构建 Windows 程序 - internal: true - cmds: - - task: build:native - vars: - ARCH: - ref: .ARCH - OUTPUT: - ref: .OUTPUT - BUILD_FLAGS: - ref: .BUILD_FLAGS - EXTRA_TAGS: - ref: .EXTRA_TAGS - DEV: - ref: .DEV - SCAN: - ref: .SCAN - - build:native: - summary: 使用 Go 原生交叉编译构建 Windows 程序 - internal: true - deps: - - task: common:go:mod:tidy - - task: common:build:frontend - vars: - BUILD_FLAGS: - ref: .BUILD_FLAGS - DEV: - ref: .DEV - SCAN: - ref: .SCAN - - task: common:generate:icons - cmds: - - task: generate:syso - vars: - ARCH: - ref: .ARCH - - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" - - cmd: powershell Remove-item *.syso - platforms: [windows] - - cmd: rm -f *.syso - platforms: [darwin, linux] - vars: - OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' - BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}' - env: - GOOS: windows - CGO_ENABLED: 0 - GOARCH: '{{.ARCH | default ARCH}}' - - package: - summary: 打包 Windows 安装程序 - internal: true - cmds: - - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' - vars: - FORMAT: '{{.FORMAT | default "nsis"}}' - - generate:syso: - summary: 生成 Windows 图标与版本信息资源 - internal: true - dir: build - cmds: - - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso - vars: - ARCH: '{{.ARCH | default ARCH}}' - - create:nsis:installer: - summary: 生成 NSIS 安装包 - internal: true - dir: build/windows/nsis - deps: - - task: build - vars: - SCAN: - ref: .SCAN - cmds: - - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" - - | - {{if eq OS "windows"}} - makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi - {{else}} - makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi - {{end}} - vars: - ARCH: '{{.ARCH | default ARCH}}' - ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' - - create:msix:package: - summary: 生成 MSIX 安装包 - internal: true - deps: - - task: build - vars: - SCAN: - ref: .SCAN - cmds: - - |- - wails3 tool msix \ - --config "{{.ROOT_DIR}}/wails.json" \ - --name "{{.APP_NAME}}" \ - --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \ - --arch "{{.ARCH}}" \ - --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \ - {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \ - {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \ - {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}} - vars: - ARCH: '{{.ARCH | default ARCH}}' - CERT_PATH: '{{.CERT_PATH | default ""}}' - PUBLISHER: '{{.PUBLISHER | default ""}}' - USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' - - create:zip: - summary: 生成 Windows ZIP 包(包含 exe) - internal: true - vars: - ARCH: '{{.ARCH | default ARCH}}' - OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' - ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}' - STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}' - cmds: - - task: build - vars: - ARCH: - ref: .ARCH - OUTPUT: - ref: .OUTPUT - BUILD_FLAGS: - ref: .BUILD_FLAGS - EXTRA_TAGS: - ref: .EXTRA_TAGS - DEV: - ref: .DEV - SCAN: - ref: .SCAN - - rm -rf "{{.STAGING_DIR}}" - - mkdir -p "{{.STAGING_DIR}}" - - cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/" - - cmd: powershell -NoProfile -Command "Compress-Archive -Path '{{.STAGING_DIR}}/*' -DestinationPath '{{.BIN_DIR}}/{{.ZIP_NAME}}' -Force" - platforms: [windows] - - cmd: (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .) - platforms: [darwin, linux] - - rm -rf "{{.STAGING_DIR}}" - - rm -f "{{.OUTPUT}}" - - run: - summary: 运行 Windows 可执行文件(仅在 Windows 可用) - vars: - OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' - cmds: - - '{{.OUTPUT}}' +version: "3" + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: 构建 Windows 程序 + internal: true + cmds: + - task: build:native + vars: + ARCH: + ref: .ARCH + OUTPUT: + ref: .OUTPUT + BUILD_FLAGS: + ref: .BUILD_FLAGS + EXTRA_TAGS: + ref: .EXTRA_TAGS + DEV: + ref: .DEV + SCAN: + ref: .SCAN + + build:native: + summary: 使用 Go 原生交叉编译构建 Windows 程序 + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + SCAN: + ref: .SCAN + - task: common:generate:icons + cmds: + - task: generate:syso + vars: + ARCH: + ref: .ARCH + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [darwin, linux] + vars: + OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}' + env: + GOOS: windows + CGO_ENABLED: 0 + GOARCH: '{{.ARCH | default ARCH}}' + + package: + summary: 打包 Windows 安装程序 + internal: true + cmds: + - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' + vars: + FORMAT: '{{.FORMAT | default "nsis"}}' + + generate:syso: + summary: 生成 Windows 图标与版本信息资源 + internal: true + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: '{{.ARCH | default ARCH}}' + + create:nsis:installer: + summary: 生成 NSIS 安装包 + internal: true + dir: build/windows/nsis + deps: + - task: build + vars: + SCAN: + ref: .SCAN + cmds: + - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" + - | + {{if eq OS "windows"}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi + {{else}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi + {{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' + + create:msix:package: + summary: 生成 MSIX 安装包 + internal: true + deps: + - task: build + vars: + SCAN: + ref: .SCAN + cmds: + - |- + wails3 tool msix \ + --config "{{.ROOT_DIR}}/wails.json" \ + --name "{{.APP_NAME}}" \ + --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \ + --arch "{{.ARCH}}" \ + --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \ + {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \ + {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \ + {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + CERT_PATH: '{{.CERT_PATH | default ""}}' + PUBLISHER: '{{.PUBLISHER | default ""}}' + USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' + + create:zip: + summary: 生成 Windows ZIP 包(包含 exe 与 certs) + internal: true + vars: + ARCH: '{{.ARCH | default ARCH}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' + ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}' + STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}' + cmds: + - task: build + vars: + ARCH: + ref: .ARCH + OUTPUT: + ref: .OUTPUT + BUILD_FLAGS: + ref: .BUILD_FLAGS + EXTRA_TAGS: + ref: .EXTRA_TAGS + DEV: + ref: .DEV + SCAN: + ref: .SCAN + - rm -rf "{{.STAGING_DIR}}" + - mkdir -p "{{.STAGING_DIR}}" + - cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/" + - (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .) + - rm -rf "{{.STAGING_DIR}}" + - rm -f "{{.OUTPUT}}" + + run: + summary: 运行 Windows 可执行文件(仅在 Windows 可用) + vars: + OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' + cmds: + - '{{.OUTPUT}}' diff --git a/build/windows/info.json b/build/windows/info.json index 115ac3e..8f344ea 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,10 +1,10 @@ { "fixed": { - "file_version": "0.0.45" + "file_version": "0.0.47" }, "info": { "0000": { - "ProductVersion": "0.0.45", + "ProductVersion": "0.0.47", "CompanyName": "Cursor助手", "FileDescription": "Cursor助手", "LegalCopyright": "© 2026, Cursor助手", diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index eccd696..293f150 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -14,7 +14,7 @@ !define INFO_PRODUCTNAME "Cursor助手" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "0.0.45" + !define INFO_PRODUCTVERSION "0.0.47" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "© 2026, Cursor助手" diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 7b30836..1754972 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + diff --git a/cursor-proxy-debugger/README.en.md b/cursor-proxy-debugger/README.en.md index 377b450..934dcd9 100644 --- a/cursor-proxy-debugger/README.en.md +++ b/cursor-proxy-debugger/README.en.md @@ -2,7 +2,7 @@ [中文](README.md) | [English](README.en.md) -This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client. +This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client. ## Start @@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger - HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged. - `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression. - `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`. +- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON. +- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it. - Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`. - The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection. - Captured traffic is stored only in process memory and is discarded when the process exits. diff --git a/cursor-proxy-debugger/README.md b/cursor-proxy-debugger/README.md index 3c39f29..934c0e1 100644 --- a/cursor-proxy-debugger/README.md +++ b/cursor-proxy-debugger/README.md @@ -2,7 +2,7 @@ [中文](README.md) | [English](README.en.md) -这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend` 和 `RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。 +这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend`、`RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。 ## 启动 @@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger - 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。 - `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。 - `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。 +- Fork Chat 相关的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。 +- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone` 和 `UploadConversationBlobs` 流量。 - 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。 - 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。 - 抓包只保留在当前进程内存中;关闭进程后消失。 diff --git a/cursor-proxy-debugger/decode.go b/cursor-proxy-debugger/decode.go index fa8b136..470716f 100644 --- a/cursor-proxy-debugger/decode.go +++ b/cursor-proxy-debugger/decode.go @@ -20,6 +20,13 @@ import ( const maxConnectFrameBytes = 64 << 20 +const ( + bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend" + forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer" + notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone" + uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs" +) + type connectFrameDecoder struct { buffer []byte messageType string @@ -140,10 +147,9 @@ func decompressPayload(payload []byte, codec string) ([]byte, error) { return decoded, nil } -func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) { - var message proto.Message +func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) { switch path { - case "/aiserver.v1.BidiService/BidiAppend": + case bidiAppendPath: request := &aiserverv1.BidiAppendRequest{} if err := proto.Unmarshal(payload, request); err != nil { return "", "", "", err @@ -165,13 +171,65 @@ func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, } formatted, marshalErr := json.MarshalIndent(combined, "", " ") return string(formatted), clientKind, requestID, marshalErr - default: - message = nil } + message, kind := unaryRequestMessage(path) if message == nil { return "", "", "", nil } - return marshalProtoJSON(message), activeOneofName(message), "", nil + if err := proto.Unmarshal(payload, message); err != nil { + return "", "", "", err + } + return marshalProtoJSON(message), kind, "", nil +} + +func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) { + message, kind := unaryResponseMessage(path) + if message == nil { + return "", "", nil + } + if err := proto.Unmarshal(payload, message); err != nil { + return "", "", err + } + return marshalProtoJSON(message), kind, nil +} + +func unaryRequestMessage(path string) (proto.Message, string) { + switch path { + case forkBackgroundComposerPath: + return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request" + case notifyConversationClonePath: + return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request" + case uploadConversationBlobsPath: + return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request" + default: + return nil, "" + } +} + +func unaryResponseMessage(path string) (proto.Message, string) { + switch path { + case forkBackgroundComposerPath: + return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response" + case notifyConversationClonePath: + return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response" + case uploadConversationBlobsPath: + return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response" + default: + return nil, "" + } +} + +func decodesUnaryRequest(path string) bool { + if path == bidiAppendPath { + return true + } + message, _ := unaryRequestMessage(path) + return message != nil +} + +func decodesUnaryResponse(path string) bool { + message, _ := unaryResponseMessage(path) + return message != nil } func newMessage(messageType string) proto.Message { diff --git a/cursor-proxy-debugger/decode_test.go b/cursor-proxy-debugger/decode_test.go new file mode 100644 index 0000000..61e8faf --- /dev/null +++ b/cursor-proxy-debugger/decode_test.go @@ -0,0 +1,242 @@ +package proxydebugger + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "strings" + "testing" + "time" + + "cursor/gen/agentv1" + "cursor/gen/aiserverv1" + + "google.golang.org/protobuf/proto" +) + +func TestDecodeForkTrafficRequests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + message proto.Message + kind string + contains []string + }{ + { + name: "fork background composer", + path: forkBackgroundComposerPath, + message: &aiserverv1.ForkBackgroundComposerRequest{ + SourceBcId: "bc-source", + Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION, + Name: proto.String("forked chat"), + TurnCount: proto.Uint32(4), + }, + kind: "fork_background_composer_request", + contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`}, + }, + { + name: "notify conversation clone", + path: notifyConversationClonePath, + message: &agentv1.NotifyConversationCloneRequest{ + ConversationId: "new-conversation", + SourceConversationId: "source-conversation", + SourceRequestId: "source-request", + }, + kind: "notify_conversation_clone_request", + contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`}, + }, + { + name: "upload conversation blobs", + path: uploadConversationBlobsPath, + message: &agentv1.UploadConversationBlobsRequest{ + ConversationId: "new-conversation", + Blobs: []*agentv1.BlobEntry{{ + Id: []byte{1, 2}, + Value: []byte("blob-value"), + }}, + ChunkIndex: 1, + TotalChunks: 2, + }, + kind: "upload_conversation_blobs_request", + contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + payload, err := proto.Marshal(test.message) + if err != nil { + t.Fatal(err) + } + decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload) + if err != nil { + t.Fatalf("decode request: %v", err) + } + if kind != test.kind { + t.Fatalf("kind = %q, want %q", kind, test.kind) + } + if requestID != "" { + t.Fatalf("request ID = %q, want empty", requestID) + } + compact := compactJSON(t, decoded) + for _, expected := range test.contains { + if !strings.Contains(compact, expected) { + t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded) + } + } + }) + } +} + +func TestDecodeForkTrafficResponses(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + message proto.Message + kind string + contains string + }{ + { + name: "fork background composer", + path: forkBackgroundComposerPath, + message: &aiserverv1.ForkBackgroundComposerResponse{ + BcId: "bc-fork", + SourceBcId: "bc-source", + Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION, + }, + kind: "fork_background_composer_response", + contains: `"bc_id":"bc-fork"`, + }, + { + name: "notify conversation clone", + path: notifyConversationClonePath, + message: &agentv1.NotifyConversationCloneResponse{}, + kind: "notify_conversation_clone_response", + contains: `{}`, + }, + { + name: "upload conversation blobs", + path: uploadConversationBlobsPath, + message: &agentv1.UploadConversationBlobsResponse{}, + kind: "upload_conversation_blobs_response", + contains: `{}`, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + payload, err := proto.Marshal(test.message) + if err != nil { + t.Fatal(err) + } + decoded, kind, err := decodeUnaryResponse(test.path, payload) + if err != nil { + t.Fatalf("decode response: %v", err) + } + if kind != test.kind { + t.Fatalf("kind = %q, want %q", kind, test.kind) + } + if !strings.Contains(compactJSON(t, decoded), test.contains) { + t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded) + } + }) + } +} + +func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) { + t.Parallel() + + payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{ + BcId: "bc-fork", + SourceBcId: "bc-source", + }) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + server := &Server{store: newExchangeStore(1)} + server.store.create(&Exchange{ + ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()}, + }) + server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil) + + exchange, ok := server.store.get("1") + if !ok { + t.Fatal("exchange was not stored") + } + if exchange.ResponseKind != "fork_background_composer_response" { + t.Fatalf("response kind = %q", exchange.ResponseKind) + } + if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) { + t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON) + } + if exchange.Response.DecodeError != "" { + t.Fatalf("decode error = %q", exchange.Response.DecodeError) + } +} + +func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) { + t.Parallel() + + payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{ + ConversationId: "new-conversation", + SourceConversationId: "source-conversation", + SourceRequestId: "source-request", + }) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write(payload); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + server := &Server{store: newExchangeStore(1)} + server.store.create(&Exchange{ + ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()}, + }) + server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil) + + exchange, ok := server.store.get("1") + if !ok { + t.Fatal("exchange was not stored") + } + if exchange.RequestKind != "notify_conversation_clone_request" { + t.Fatalf("request kind = %q", exchange.RequestKind) + } + if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) { + t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON) + } + if exchange.Request.DecodeError != "" { + t.Fatalf("decode error = %q", exchange.Request.DecodeError) + } +} + +func compactJSON(t *testing.T, value string) string { + t.Helper() + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(value)); err != nil { + t.Fatalf("compact JSON: %v\n%s", err, value) + } + return compact.String() +} diff --git a/cursor-proxy-debugger/proxy.go b/cursor-proxy-debugger/proxy.go index 11d15d7..21a53c3 100644 --- a/cursor-proxy-debugger/proxy.go +++ b/cursor-proxy-debugger/proxy.go @@ -226,28 +226,29 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy. if id == "" || response == nil { return response } + path := "" + if response.Request != nil && response.Request.URL != nil { + path = response.Request.URL.Path + } + responseCodec := responseContentCodec(path, response.Header) server.store.update(id, func(exchange *Exchange) { exchange.Status = response.StatusCode exchange.State = "streaming" exchange.DurationMS = elapsedMS(exchange.StartedAt) exchange.Response.Headers = sortedHeaders(response.Header) exchange.Response.ContentType = response.Header.Get("Content-Type") - exchange.Response.ContentCodec = responseContentCodec(response.Header) + exchange.Response.ContentCodec = responseCodec }) if response.Body == nil { - server.finishResponseBody(id, nil, 0, false, nil) + server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil) return response } - path := "" - if response.Request != nil && response.Request.URL != nil { - path = response.Request.URL.Path - } var frameDecoder *connectFrameDecoder if path == "/agent.v1.AgentService/RunSSE" { frameDecoder = newConnectFrameDecoder( "agent.v1.AgentServerMessage", - response.Header.Get("Connect-Content-Encoding"), + responseCodec, server.config.MaxFrames, func(frame FrameView) { server.appendResponseFrame(id, frame) }, ) @@ -264,7 +265,7 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy. if frameDecoder != nil { frameDecoder.Close() } - server.finishResponseBody(id, captured, size, truncated, readErr) + server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr) }, ) return response @@ -273,14 +274,14 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy. func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) { decodePayload := captured var contentDecodeErr error - if path == "/aiserver.v1.BidiService/BidiAppend" && truncated { + if decodesUnaryRequest(path) && truncated { contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码") - } else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") { + } else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") { decodePayload, contentDecodeErr = decompressPayload(captured, codec) } decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr if decodeErr == nil { - decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload) + decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload) } server.store.update(id, func(exchange *Exchange) { exchange.RequestBytes = size @@ -312,19 +313,44 @@ func requestContentCodec(path string, headers http.Header) string { return strings.TrimSpace(headers.Get("Content-Encoding")) } -func responseContentCodec(headers http.Header) string { - if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" { - return codec +func responseContentCodec(path string, headers http.Header) string { + if path == "/agent.v1.AgentService/RunSSE" { + return strings.TrimSpace(headers.Get("Connect-Content-Encoding")) + } + if !decodesUnaryResponse(path) { + if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" { + return codec + } } return strings.TrimSpace(headers.Get("Content-Encoding")) } -func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) { +func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) { + decodePayload := captured + var contentDecodeErr error + if decodesUnaryResponse(path) && truncated { + contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码") + } else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") { + decodePayload, contentDecodeErr = decompressPayload(captured, codec) + } + decodedJSON, kind, decodeErr := "", "", contentDecodeErr + if decodeErr == nil { + decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload) + } server.store.update(id, func(exchange *Exchange) { exchange.ResponseBytes = size exchange.Response.Size = size exchange.Response.RawHex = rawHex(captured) exchange.Response.RawTruncated = truncated + if decodedJSON != "" { + exchange.Response.DecodedJSON = decodedJSON + } + if kind != "" { + exchange.ResponseKind = kind + } + if decodeErr != nil { + exchange.Response.DecodeError = decodeErr.Error() + } exchange.DurationMS = elapsedMS(exchange.StartedAt) exchange.State = "completed" if readErr != nil && !errors.Is(readErr, io.EOF) { diff --git a/cursor-proxy-debugger/web/app.js b/cursor-proxy-debugger/web/app.js index b854047..9181368 100644 --- a/cursor-proxy-debugger/web/app.js +++ b/cursor-proxy-debugger/web/app.js @@ -138,6 +138,7 @@ function filteredExchanges() { .filter((item) => { if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false; if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false; + if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false; if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false; if (!query) return true; return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)] @@ -151,6 +152,13 @@ function filteredExchanges() { }); } +function isForkTrafficPath(path) { + const normalized = String(path || "").toLowerCase(); + return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) => + normalized.includes(endpoint), + ); +} + function renderList() { const exchanges = filteredExchanges(); elements.requestCount.textContent = t("count.requests", { count: exchanges.length }); diff --git a/cursor-proxy-debugger/web/i18n.js b/cursor-proxy-debugger/web/i18n.js index ebfd5fd..ec79089 100644 --- a/cursor-proxy-debugger/web/i18n.js +++ b/cursor-proxy-debugger/web/i18n.js @@ -22,6 +22,7 @@ const messages = { "filters.requestIdPlaceholder": "按 Request ID 过滤", "filters.endpoint": "接口过滤", "filters.all": "全部", + "filters.fork": "Fork", "filters.sort": "排序方向", "filters.ascending": "正序", "filters.descending": "倒序", @@ -81,6 +82,7 @@ const messages = { "filters.requestIdPlaceholder": "Filter by Request ID", "filters.endpoint": "Endpoint filter", "filters.all": "All", + "filters.fork": "Fork", "filters.sort": "Sort order", "filters.ascending": "Oldest first", "filters.descending": "Newest first", diff --git a/cursor-proxy-debugger/web/index.html b/cursor-proxy-debugger/web/index.html index 34eddb4..c9b2dfe 100644 --- a/cursor-proxy-debugger/web/index.html +++ b/cursor-proxy-debugger/web/index.html @@ -46,6 +46,7 @@ +
diff --git a/frontend/package.json b/frontend/package.json index 6a1ff35..59027fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "copy-text-to-clipboard": "^3.2.2", "dayjs": "^1.11.20", "resize-observer-polyfill": "^1.5.1", + "sortablejs": "^1.15.7", "vue": "^3.5.22", "vue-chartjs": "^5.3.3", "vue-router": "^4.6.3" diff --git a/frontend/src/components/AdModelProvider.vue b/frontend/src/components/AdModelProvider.vue index 20e9c1f..f4e3aa2 100644 --- a/frontend/src/components/AdModelProvider.vue +++ b/frontend/src/components/AdModelProvider.vue @@ -196,6 +196,7 @@ onBeforeUnmount(() => {