refactor: 0.1.0-beta

This commit is contained in:
leokun
2026-08-13 22:01:18 +08:00
parent 3e7a15017d
commit eafede22e3
28 changed files with 4744 additions and 69 deletions
View File
@@ -1,64 +0,0 @@
---
name: cursor-proxy-debugger
description: Maintain, diagnose, extend, and validate the standalone Cursor HTTPS protocol debugger in cursor-proxy-debugger. Use when changing its command startup, MITM capture behavior, Connect streaming or protobuf decoding, SQLite persistence, local debugging API, embedded web UI, tests, documentation, or when investigating captured Cursor BidiAppend, RunSSE, Fork Chat, or model-discovery traffic.
---
# Cursor Proxy Debugger
Treat `cursor-proxy-debugger` as an independent Go module and executable project. Keep its command entry point and all debugger-specific assets in that directory.
## Respect the project boundary
- Keep every Go file in the project root in `package main`; do not recreate a command directory in the main repository.
- Reuse the shared CA and generated Cursor protobuf packages from `cursor-byok` rather than copying them.
- The canonical proto sources are `cursor-byok/internal/backend/cursor/proto`; update or regenerate them in the main repository when schemas change.
- Keep the tool observational: never modify Cursor settings, system proxy settings, or the installed client automatically.
- Bind the debugging UI to loopback addresses only. Continue passing non-target CONNECT traffic through without MITM.
- Preserve forwarded request and response bodies even when local capture limits truncate stored copies.
## Locate the responsibility
- `main.go`: flags, startup output, browser opening, signals, and graceful shutdown.
- `proxy.go` and `capture.go`: listeners, target matching, MITM, streaming capture, and forwarding.
- `decode.go`: Connect envelopes, compression, protobuf message selection, and JSON views.
- `decode_stored.go`: persisted payload hydration and stored protobuf/text views.
- `proxy_capture.go`: request/response body capture and frame event assembly.
- `store.go`: hot-memory state, SQLite persistence, subscriptions, and conversation queries.
- `store_queries.go`: persisted exchange queries, cloning, redaction helpers, and subscriptions.
- `types.go`: configuration and API-facing capture models.
- `web.go`: loopback API, SSE events, CA download, security headers, and embedded assets.
- `web/app.js`: page state, rendering, Monaco editor lifecycle, and bootstrap.
- `web/app_events.js`: UI event binding for filters, details, pause, and resizing.
- `web/view_helpers.js`: display formatting, HTML escaping, and copy-text helpers.
- `web/styles*.css`: split base, control, detail, and responsive stylesheets.
- `web/`: dependency-free debugging UI and its Chinese/English text.
## Follow the change workflow
1. Inspect `git status` and the relevant staged and unstaged diffs before editing; captures and debugger files may already contain user work.
2. Read the smallest responsible source files. This standalone temporary debugger intentionally does not carry a test suite; for backend, MITM, or routing changes in formal product modules, also follow `chinese-code-style` and its `MODULES.md` boundary rules.
3. For a new protocol endpoint, confirm the exact URL path, request/response direction, streaming mode, compression, and generated protobuf message type. Do not infer schemas from similar endpoints.
4. Decode incrementally across arbitrary read boundaries. Treat Connect flags and the five-byte frame header as protocol data, and keep malformed-frame errors visible without breaking upstream forwarding.
5. Redact sensitive headers in every newly exposed API or UI path. Never log or render authorization material by default.
6. When changing UI text, update both locale tables in `web/i18n.js`, keep `data-i18n` keys aligned, and verify the fallback language.
7. Update `README.md` and `README.en.md` together when commands, flags, supported traffic, storage, or setup steps change.
## TDD boundary and proportional validation
- Formal product modules must follow TDD: write or update a focused failing test first, implement the smallest change that makes it pass, then refactor while keeping the test green.
- This project is a temporary observational tool, so TDD is not mandatory and test files may be intentionally omitted. Validate it with formatting, build checks, the style checker, and targeted manual smoke checks instead.
- Format changed Go files with `gofmt`.
- Run `go build -o <temporary-path>/cursor-proxy-debugger .` from the standalone project after entry-point, dependency, embed, or build-task changes. Do not require tests while this temporary project has no tests.
- Run the Chinese style checker on changed handwritten source files.
- For UI changes, start with `go run . -open=false` when safe, query `/api/status`, and inspect the page in a browser if layout or interaction changed.
- For capture or decoding changes, perform focused manual checks for split reads, compressed frames, malformed input, endpoint direction, persistence, or pass-through behavior as applicable.
## Use the canonical commands
From `cursor-proxy-debugger`:
```bash
go run .
go build -o ./bin/cursor-proxy-debugger .
```
@@ -1,4 +0,0 @@
interface:
display_name: "Cursor Proxy Debugger"
short_description: "维护、诊断并验证独立的 Cursor HTTPS 协议调试代理"
default_prompt: "Use $cursor-proxy-debugger to diagnose or modify the standalone Cursor protocol debugging proxy at /Users/leokun/Documents/cursor-proxy-debugger."
+70
View File
@@ -0,0 +1,70 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local Cursor API debugging service forwards every HTTP request outside the `__debuger__` debugging namespace to the fixed upstream `https://api2.cursor.sh`, preserving the method, path, query, headers, and body. It continues to capture `BidiAppend`, `RunSSE`, Fork Chat, and model-discovery traffic.
It is not a general-purpose HTTP proxy, does not handle `CONNECT`, requires no CA certificate, and does not modify the system proxy.
## Start
Generate the sibling `cursor-proto` module's Go code before the first build:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
The service listens on a single port:
- Cursor API service: `http://127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9090/__debuger__/`
- Debugging API: `http://127.0.0.1:9090/__debuger__/api/*`
- Fixed upstream: `https://api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
Quit Cursor completely, then launch it from a terminal with the local API address:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` overrides the Agent API endpoint. `CURSOR_API_BASE_URL` also routes requests such as authentication that use the base API address through this service. Cursor proxy and Network settings do not need to be changed.
## Build
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## Dependency Layout
The debugger is an independent Go module. Cursor protobuf message packages are generated by the sibling `cursor-proto` module. The generated `gen/` directory is not committed, so run its `scripts/generate.sh` before the first build. This project does not depend on the outer `cursor-byok` Go module.
## Options
```text
-addr Cursor API service listen address; default: 127.0.0.1:9090
-max-exchanges Maximum exchanges retained in memory; default: 200
-db SQLite database path; defaults to the user configuration directory
-open Open the browser after startup; default: true
```
## Data Handling
- Every request received by the service is forwarded to `https://api2.cursor.sh`; clients cannot select another upstream.
- The `__debuger__` namespace is reserved for the local debugging page and API and is never forwarded upstream.
- `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.
- `CppService/AvailableModels`, `AiService/AvailableModels`, `GetDefaultModel`, and `GetDefaultModelNudgeData` are decoded bidirectionally.
- Requests can be filtered by time and protocol `request_id`; the UI can query by `conversation_id` and group requests by conversation.
- Complete captures are stored in SQLite and remain queryable after restart; `max-exchanges` only limits hot in-memory data.
- Sensitive headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; capture limits never truncate forwarded traffic.
+70
View File
@@ -0,0 +1,70 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 Cursor API 调试服务。除 `__debuger__` 调试命名空间外,进入服务端口的 HTTP 请求都会保留方法、路径、查询参数、请求头和请求体,并转发到固定上游 `https://api2.cursor.sh`。服务同时记录 `BidiAppend``RunSSE`、Fork Chat 和模型发现等流量。
它不是通用 HTTP 代理,不处理 `CONNECT`,不需要 CA 证书,也不会修改系统代理。
## 启动
首次构建前先生成相邻 `cursor-proto` 项目的 Go 代码:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
服务只监听一个端口:
- Cursor API 服务:`http://127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9090/__debuger__/`
- 调试 API`http://127.0.0.1:9090/__debuger__/api/*`
- 固定上游:`https://api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
完全退出 Cursor 后,从终端指定本地 API 地址启动:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` 覆盖 Agent API 地址;`CURSOR_API_BASE_URL` 让使用基础 API 地址的认证等请求也经过本服务。无需修改 Cursor 代理设置或 Network 设置。
## 构建
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## 依赖说明
调试器是独立 Go module。Cursor protobuf 消息包由相邻的 `cursor-proto` module 生成;生成的 `gen/` 目录不提交到 Git,因此首次构建前需要运行其 `scripts/generate.sh`。本项目不依赖外层 `cursor-byok` Go module。
## 参数
```text
-addr Cursor API 服务监听地址,默认 127.0.0.1:9090
-max-exchanges 内存中保留的最大请求数,默认 200
-db SQLite 数据库路径,默认位于用户配置目录
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 所有服务端口收到的请求都固定转发到 `https://api2.cursor.sh`,不会接受客户端指定的其他上游。
- `__debuger__` 命名空间由本地调试页面和调试 API 保留,不会转发到上游。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- `CppService/AvailableModels``AiService/AvailableModels``GetDefaultModel``GetDefaultModelNudgeData` 会双向解码模型相关数据。
- 请求列表支持按时间和协议 `request_id` 过滤;调试界面可按 `conversation_id` 查询并按会话分组。
- 完整抓包写入 SQLite,重启后仍可查询;`max-exchanges` 只限制内存热数据数量。
- `Authorization``Cookie``Set-Cookie` 等敏感请求头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;转发内容不会被抓取上限截断。
+95
View File
@@ -0,0 +1,95 @@
// capture.go 在不影响上游转发的前提下截取有限大小的 HTTP 流。
package main
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
// captureReadCloser 包装响应体并并发安全地累计诊断副本。
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
// newCaptureReadCloser 创建带分块和完成回调的捕获读取器。
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
// Read 转发读取结果并保存不超过限制的副本。
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
// Close 关闭原始响应体并保证完成回调只执行一次。
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
// finish 固化捕获快照并在锁外调用完成回调。
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
// rawHex 把捕获字节编码为便于 JSON 持久化的十六进制文本。
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
+345
View File
@@ -0,0 +1,345 @@
// capture_pipeline.go 负责服务请求响应体的捕获、解码和事件追加。
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
)
// exchangeIDContextKey 隔离反向服务内部使用的捕获编号。
type exchangeIDContextKey struct{}
// captureRequest 捕获请求元数据并安装请求体读取器。
func (server *Server) captureRequest(request *http.Request) *http.Request {
if request == nil {
return request
}
server.captureMu.RLock()
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
upstreamURL := *request.URL
upstreamURL.Scheme = server.upstream.Scheme
upstreamURL.Host = server.upstream.Host
upstreamURL.User = nil
requestContentType := request.Header.Get("Content-Type")
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: upstreamURL.String(),
Host: server.upstream.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: requestContentType,
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
server.captureMu.RUnlock()
request = request.WithContext(context.WithValue(request.Context(), exchangeIDContextKey{}, id))
request.Close = false
if request.Body == nil {
server.finishRequestBody(id, path, requestContentType, requestCodec, nil, 0, false, nil)
return request
}
var frameDecoder *connectFrameDecoder
if messageType := streamingRequestMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestContentType, requestCodec, captured, size, truncated, readErr)
},
)
return request
}
// clearExchanges 清空内存和持久化捕获,并重置递增编号。
func (server *Server) clearExchanges() error {
server.captureMu.Lock()
defer server.captureMu.Unlock()
if err := server.store.clear(); err != nil {
return err
}
server.counter.Store(0)
return nil
}
// captureResponse 创建响应记录更新并包装响应体捕获器。
func (server *Server) captureResponse(response *http.Response) error {
if response == nil {
return nil
}
id := exchangeID(response.Request)
if id == "" {
return nil
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
responseContentType := response.Header.Get("Content-Type")
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 = responseContentType
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, path, responseContentType, responseCodec, nil, 0, false, nil)
return nil
}
var frameDecoder *connectFrameDecoder
if messageType := streamingResponseMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, path, responseContentType, responseCodec, captured, size, truncated, readErr)
},
)
return nil
}
// failExchange 保存反向转发失败状态。
func (server *Server) failExchange(request *http.Request, upstreamErr error) {
id := exchangeID(request)
if id == "" || upstreamErr == nil {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = upstreamErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
// finishRequestBody 解压、解码并保存完整请求体的最终状态。
func (server *Server) finishRequestBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryRequest(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, requestID, conversationID, decodeErr := "", "", "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, requestID, conversationID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
exchange.Request.DecodedLang = decodedLang
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
// requestContentCodec 读取请求方向的 Connect 或 HTTP 压缩编码。
func requestContentCodec(path string, headers http.Header) string {
if streamingRequestMessageType(path) != "" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
// responseContentCodec 读取响应方向的 Connect 或 HTTP 压缩编码。
func responseContentCodec(path string, headers http.Header) string {
if streamingResponseMessageType(path) != "" {
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"))
}
// finishResponseBody 解压、解码并保存完整响应体的最终状态。
func (server *Server) finishResponseBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryResponse(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, decodeErr := "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
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
exchange.Response.DecodedLang = decodedLang
}
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) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
// appendRequestFrame 把请求方向的流式帧追加到临时快照。
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
// appendResponseFrame 把响应方向的流式帧追加到临时快照。
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
// exchangeID 从请求上下文读取捕获记录编号。
func exchangeID(request *http.Request) string {
if request == nil {
return ""
}
value, ok := request.Context().Value(exchangeIDContextKey{}).(string)
if !ok {
return ""
}
return value
}
// browserAddress 把通配监听地址转换为浏览器可访问的回环地址。
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// validateLoopbackAddress 拒绝把调试服务暴露到非回环网卡。
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试服务监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试服务只能监听本机回环地址")
}
return nil
}
+380
View File
@@ -0,0 +1,380 @@
// decode.go 解析 Connect 帧、压缩载荷和 Cursor protobuf 消息视图。
package main
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/proto"
)
// maxConnectFrameBytes 防止异常帧长度导致调试器分配过大内存。
const maxConnectFrameBytes = 64 << 20
// 协议路径常量用于选择精确的 protobuf 请求、响应和流式消息类型。
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
cppAvailableModelsPath = "/aiserver.v1.CppService/AvailableModels"
aiAvailableModelsPath = "/aiserver.v1.AiService/AvailableModels"
aiGetDefaultModelPath = "/aiserver.v1.AiService/GetDefaultModel"
aiDefaultModelNudgeDataPath = "/aiserver.v1.AiService/GetDefaultModelNudgeData"
mcpGetKnownServersPath = "/aiserver.v1.MCPRegistryService/GetKnownServers"
serverGetConfigPath = "/aiserver.v1.ServerConfigService/GetServerConfig"
runSSEPath = "/agent.v1.AgentService/RunSSE"
)
// connectFrameDecoder 在任意读取边界下累计并解析 Connect 五字节帧。
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
// newConnectFrameDecoder 创建指定 protobuf 类型的流式解码器。
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
// Write 追加任意长度的网络片段并尽可能产出完整帧。
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
// Close 标记流结束并暴露尚未完整的尾部错误。
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
// emit 在达到帧数上限前调用帧回调。
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
// decode 解压并解析单条 Connect 帧。
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
// decompressPayload 使用协议声明的编码解压载荷。
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
// decodeUnaryRequest 解析单次 RPC 请求并提取关键关联标识。
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, conversationID string, err error) {
switch path {
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := decodeBidiClientMessage(request)
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, "", decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, conversationIDFromClientMessage(clientMessage), marshalErr
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", "", err
}
return marshalProtoJSON(message), kind, "", conversationIDFromUnaryRequest(message), nil
}
// decodeBidiClientMessage 解析 BidiAppend 携带的十六进制 Agent 消息。
func decodeBidiClientMessage(request *aiserverv1.BidiAppendRequest) (*agentv1.AgentClientMessage, string, error) {
if request == nil {
return nil, "", nil
}
if strings.TrimSpace(request.GetData()) != "" {
payload, err := hex.DecodeString(strings.TrimSpace(request.GetData()))
if err != nil {
return nil, "", fmt.Errorf("decode hex agent client message failed: %w", err)
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(payload, message); err != nil {
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
if len(request.GetDataBinary()) == 0 {
return nil, "", nil
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(request.GetDataBinary(), message); err != nil {
return nil, "", fmt.Errorf("decode binary agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
// conversationIDFromClientMessage 从 Agent 消息的会话字段提取会话标识。
func conversationIDFromClientMessage(message *agentv1.AgentClientMessage) string {
if message == nil {
return ""
}
if runRequest := message.GetRunRequest(); runRequest != nil {
return strings.TrimSpace(runRequest.GetConversationId())
}
if prewarmRequest := message.GetPrewarmRequest(); prewarmRequest != nil {
return strings.TrimSpace(prewarmRequest.GetConversationId())
}
return ""
}
// conversationIDFromUnaryRequest 从已知 RPC 请求中提取会话标识。
func conversationIDFromUnaryRequest(message proto.Message) string {
switch typed := message.(type) {
case *agentv1.NotifyConversationCloneRequest:
return strings.TrimSpace(typed.GetConversationId())
case *agentv1.UploadConversationBlobsRequest:
return strings.TrimSpace(typed.GetConversationId())
default:
return ""
}
}
// decodeUnaryResponse 解析单次 RPC 响应并生成 JSON 视图。
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
}
// hydrateStoredExchange 为历史捕获补齐正文和 Connect 帧视图。
func hydrateStoredExchange(exchange *Exchange) bool {
if exchange == nil || (exchange.State != "completed" && exchange.State != "streaming") {
return false
}
changed := false
if messageType := streamingRequestMessageType(exchange.Path); messageType != "" &&
len(exchange.Request.Frames) == 0 && exchange.Request.RawHex != "" && !exchange.Request.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Request.RawHex, messageType, exchange.Request.ContentCodec)
if err != nil {
exchange.Request.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Request.Frames = frames
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
}
}
changed = true
}
if messageType := streamingResponseMessageType(exchange.Path); messageType != "" &&
len(exchange.Response.Frames) == 0 && exchange.Response.RawHex != "" && !exchange.Response.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Response.RawHex, messageType, exchange.Response.ContentCodec)
if err != nil {
exchange.Response.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Response.Frames = frames
exchange.FrameCount = len(frames)
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
}
}
changed = true
}
if isUnaryProtoContentType(exchange.Request.ContentType) && exchange.Request.DecodedJSON == "" && !exchange.Request.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Request.RawHex, exchange.Request.ContentCodec)
if err == nil {
decoded, kind, requestID, conversationID, decodeErr := decodeUnaryRequest(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Request.DecodedJSON = decoded
exchange.Request.DecodedLang = "json"
exchange.RequestKind = kind
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
changed = true
}
}
if err != nil {
exchange.Request.DecodeError = err.Error()
changed = true
}
}
if isUnaryProtoContentType(exchange.Response.ContentType) && exchange.Response.DecodedJSON == "" && !exchange.Response.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Response.RawHex, exchange.Response.ContentCodec)
if err == nil {
decoded, kind, decodeErr := decodeUnaryResponse(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Response.DecodedJSON = decoded
exchange.Response.DecodedLang = "json"
exchange.ResponseKind = kind
changed = true
}
}
if err != nil {
exchange.Response.DecodeError = err.Error()
changed = true
}
}
if exchange.Request.DecodedJSON == "" && exchange.Request.RawHex != "" &&
!isProtoContentType(exchange.Request.ContentType) && streamingRequestMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Request) {
changed = true
}
}
if exchange.Response.DecodedJSON == "" && exchange.Response.RawHex != "" &&
!isProtoContentType(exchange.Response.ContentType) && streamingResponseMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Response) {
changed = true
}
}
return changed
}
// hydrateStoredTextPayload 为历史文本载荷补齐 JSON 视图。
func hydrateStoredTextPayload(payload *Payload) bool {
raw, err := hex.DecodeString(strings.TrimSpace(payload.RawHex))
if err != nil {
payload.DecodeError = fmt.Sprintf("解析已存储正文失败:%v", err)
return true
}
decoded, language, decodeErr := decodeCapturedContent(raw, payload.ContentType, payload.ContentCodec)
if decoded == "" && decodeErr == nil {
return false
}
payload.DecodedJSON = decoded
payload.DecodedLang = language
if decodeErr != nil {
payload.DecodeError = decodeErr.Error()
}
return true
}
// decodeCapturedContent 按媒体类型和压缩编码解码任意捕获正文。
+397
View File
@@ -0,0 +1,397 @@
// decode_stored.go 负责从持久化捕获记录恢复文本、帧和 protobuf 视图。
package main
import (
"bytes"
"compress/zlib"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/andybalholm/brotli"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/dynamicpb"
)
// decodeCapturedContent 按内容类型和压缩编码生成可读正文视图。
func decodeCapturedContent(payload []byte, contentType, codec string) (string, string, error) {
decoded, err := decodeHTTPContent(payload, codec)
if err != nil {
return "", "", err
}
if len(decoded) == 0 {
return "", "", nil
}
mediaType := normalizedMediaType(contentType)
if json.Valid(decoded) {
var formatted bytes.Buffer
if err := json.Indent(&formatted, decoded, "", " "); err != nil {
return string(decoded), "json", err
}
return formatted.String(), "json", nil
}
if mediaType == "application/x-www-form-urlencoded" && utf8.Valid(decoded) {
values, parseErr := url.ParseQuery(string(decoded))
if parseErr != nil {
return string(decoded), "plaintext", parseErr
}
formatted, marshalErr := json.MarshalIndent(values, "", " ")
return string(formatted), "json", marshalErr
}
if !isTextMediaType(mediaType) || !utf8.Valid(decoded) {
return "", "", nil
}
if strings.ContainsRune(string(decoded), '\x00') {
return "", "", nil
}
language := textLanguage(mediaType)
if strings.HasSuffix(mediaType, "+json") || mediaType == "application/json" {
return string(decoded), "json", fmt.Errorf("JSON 正文格式无效")
}
return string(decoded), language, nil
}
// decodeHTTPContent 解压 HTTP 内容编码并返回正文副本。
func decodeHTTPContent(payload []byte, codec string) ([]byte, error) {
encodings := strings.Split(strings.TrimSpace(codec), ",")
decoded := payload
for index := len(encodings) - 1; index >= 0; index-- {
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
switch encoding {
case "", "identity":
case "gzip", "x-gzip":
var err error
decoded, err = decompressPayload(decoded, "gzip")
if err != nil {
return nil, err
}
case "deflate":
reader, err := zlib.NewReader(bytes.NewReader(decoded))
if err != nil {
return nil, fmt.Errorf("deflate 解压失败:%w", err)
}
result, readErr := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
closeErr := reader.Close()
if readErr != nil {
return nil, fmt.Errorf("读取 deflate 内容失败:%w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("关闭 deflate 内容失败:%w", closeErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("deflate 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
case "br":
result, readErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(decoded)), maxConnectFrameBytes+1))
if readErr != nil {
return nil, fmt.Errorf("读取 Brotli 内容失败:%w", readErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("Brotli 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
default:
return nil, fmt.Errorf("暂不支持内容编码 %q", encoding)
}
}
return decoded, nil
}
// normalizedMediaType 删除参数并统一媒体类型大小写。
func normalizedMediaType(contentType string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err == nil {
return strings.ToLower(mediaType)
}
return strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
}
// isProtoContentType 判断媒体类型是否表示 protobuf 二进制。
func isProtoContentType(contentType string) bool {
return strings.Contains(normalizedMediaType(contentType), "proto")
}
// isTextMediaType 判断媒体类型是否适合直接作为文本展示。
func isTextMediaType(mediaType string) bool {
return strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") ||
strings.HasSuffix(mediaType, "+xml") || mediaType == "application/json" ||
mediaType == "application/xml" || mediaType == "application/javascript" ||
mediaType == "application/x-javascript" || mediaType == "application/graphql"
}
// textLanguage 为前端编辑器选择文本语言。
func textLanguage(mediaType string) string {
switch {
case strings.Contains(mediaType, "json"):
return "json"
case strings.Contains(mediaType, "xml"):
return "xml"
case strings.Contains(mediaType, "html"):
return "html"
case strings.Contains(mediaType, "javascript"):
return "javascript"
case strings.Contains(mediaType, "css"):
return "css"
default:
return "plaintext"
}
}
// decodeStoredConnectFrames 从持久化十六进制载荷恢复流式帧。
func decodeStoredConnectFrames(rawHexValue, messageType, codec string) ([]FrameView, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储 Connect 正文失败:%w", err)
}
frames := make([]FrameView, 0)
decoder := newConnectFrameDecoder(messageType, codec, defaultMaxFrames, func(frame FrameView) {
frames = append(frames, frame)
})
decoder.Write(payload)
decoder.Close()
return frames, nil
}
// isUnaryProtoContentType 判断媒体类型是否为可直接解码的 protobuf。
func isUnaryProtoContentType(contentType string) bool {
mediaType := normalizedMediaType(contentType)
return mediaType == "application/proto" || mediaType == "application/protobuf" || mediaType == "application/x-protobuf"
}
// decodeStoredRawPayload 解码持久化原始载荷并应用压缩处理。
func decodeStoredRawPayload(rawHexValue, codec string) ([]byte, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储正文失败:%w", err)
}
if codec == "" || strings.EqualFold(codec, "identity") {
return payload, nil
}
return decompressPayload(payload, codec)
}
// unaryRequestMessage 根据 RPC 路径创建请求消息和稳定类型名。
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"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsRequest{}, "available_cpp_models_request"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsRequest{}, "available_models_request"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelRequest{}, "get_default_model_request"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataRequest{}, "get_default_model_nudge_data_request"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersRequest{}, "get_known_servers_request"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigRequest{}, "get_server_config_request"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Input()), protoMessageKind(method.Input())
}
}
// unaryResponseMessage 根据 RPC 路径创建响应消息和稳定类型名。
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"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsResponse{}, "available_cpp_models_response"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsResponse{}, "available_models_response"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelResponse{}, "get_default_model_response"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataResponse{}, "get_default_model_nudge_data_response"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersResponse{}, "get_known_servers_response"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigResponse{}, "get_server_config_response"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Output()), protoMessageKind(method.Output())
}
}
// streamingRequestMessageType 返回流式请求的 protobuf 类型名。
func streamingRequestMessageType(path string) string {
if path == runSSEPath {
return "aiserver.v1.BidiRequestId"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Input().FullName())
}
// streamingResponseMessageType 返回流式响应的 protobuf 类型名。
func streamingResponseMessageType(path string) string {
if path == runSSEPath {
return "agent.v1.AgentServerMessage"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Output().FullName())
}
// decodesUnaryRequest 判断是否存在已知的一元请求解码器。
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
// decodesUnaryResponse 判断是否存在已知的一元响应解码器。
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
// newMessage 按完整 protobuf 类型名从注册表创建消息实例。
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(messageType))
if err != nil {
return nil
}
messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor)
if !ok {
return nil
}
return dynamicpb.NewMessage(messageDescriptor)
}
}
// rpcMethodDescriptor 通过完整 RPC 路径查找注册表中的方法描述。
func rpcMethodDescriptor(path string) protoreflect.MethodDescriptor {
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil
}
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(parts[0]))
if err != nil {
return nil
}
service, ok := descriptor.(protoreflect.ServiceDescriptor)
if !ok {
return nil
}
return service.Methods().ByName(protoreflect.Name(parts[1]))
}
// protoMessageKind 从消息描述推导稳定的 JSON kind 名称。
func protoMessageKind(descriptor protoreflect.MessageDescriptor) string {
if descriptor == nil {
return ""
}
return snakeCase(string(descriptor.Name()))
}
// snakeCase 将 protobuf 名称转换为前端稳定的下划线命名。
func snakeCase(value string) string {
var result strings.Builder
for index, character := range value {
if unicode.IsUpper(character) {
if index > 0 {
result.WriteByte('_')
}
result.WriteRune(unicode.ToLower(character))
continue
}
result.WriteRune(character)
}
return result.String()
}
// marshalProtoJSON 把 protobuf 消息编码为前端可读 JSON。
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
// activeOneofName 返回 Agent 消息当前激活的 oneof 名称。
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
// prettyJSON 尝试格式化 JSON,失败时返回原始文本。
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
// clippedHex 限制原始载荷展示长度并标记省略部分。
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
+25
View File
@@ -0,0 +1,25 @@
module github.com/leookun/cursor-proxy-debugger
go 1.25.8
require (
github.com/andybalholm/brotli v1.2.0
github.com/leookun/cursor-byok/cursor-proto v0.0.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/leookun/cursor-byok/cursor-proto => ../cursor-proto
+62
View File
@@ -0,0 +1,62 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+50
View File
@@ -0,0 +1,50 @@
// cursor-proxy-debugger 提供独立 Cursor API 调试服务的进程入口。
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/pkg/browser"
)
// main 解析启动参数,并管理调试服务的完整生命周期。
func main() {
config := Config{}
openBrowser := true
flag.StringVar(&config.ServiceAddr, "addr", defaultServiceAddr, "Cursor API 调试服务监听地址")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.StringVar(&config.DatabasePath, "db", "", "SQLite 数据库路径(默认使用用户配置目录)")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor API 调试服务已启动\n")
fmt.Printf("服务地址: http://%s\n", server.ServiceAddr())
fmt.Printf("固定上游: %s\n", defaultUpstreamURL)
fmt.Printf("调试界面: %s\n", server.UIURL())
fmt.Printf("SQLite: %s\n", server.DatabasePath())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
signal.Stop(signals)
if err := server.Close(); err != nil {
log.Printf("关闭调试服务失败:%v", err)
}
}
+144
View File
@@ -0,0 +1,144 @@
// server.go 负责固定上游服务、流量捕获和调试界面的生命周期。
package main
import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"sync/atomic"
"time"
)
// Server 运行 Cursor API 转发服务及其本机调试界面。
type Server struct {
config Config
upstream *url.URL
store *exchangeStore
counter atomic.Uint64
serviceServer *http.Server
serviceLn net.Listener
runMu sync.Mutex
captureMu sync.RWMutex
}
// New 创建固定转发到 Cursor API 的协议调试服务。
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.ServiceAddr); err != nil {
return nil, fmt.Errorf("服务监听地址无效:%w", err)
}
upstream, err := url.Parse(defaultUpstreamURL)
if err != nil {
return nil, fmt.Errorf("解析固定上游地址:%w", err)
}
store, err := newPersistentExchangeStore(config.DatabasePath, config.MaxExchanges)
if err != nil {
return nil, err
}
server := &Server{
config: config,
upstream: upstream,
store: store,
}
server.counter.Store(store.maxNumericID())
server.serviceServer = &http.Server{
Handler: server.newServiceHandler(),
ErrorLog: log.New(io.Discard, "", 0),
}
return server, nil
}
// Start 启动同时承载 API 转发和调试界面的单端口服务。
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.serviceLn != nil {
return errors.New("Cursor API 调试服务已经启动")
}
serviceListener, err := net.Listen("tcp", server.config.ServiceAddr)
if err != nil {
return fmt.Errorf("启动 API 服务监听失败:%w", err)
}
server.serviceLn = serviceListener
go func() { _ = server.serviceServer.Serve(serviceListener) }()
return nil
}
// Close 立即关闭监听器、活跃连接并释放捕获存储。
func (server *Server) Close() error {
server.runMu.Lock()
serviceServer := server.serviceServer
server.serviceLn = nil
server.runMu.Unlock()
var errorsList []error
if serviceServer != nil {
if err := serviceServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errorsList = append(errorsList, err)
}
}
if server.store != nil {
if err := server.store.close(); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
// ServiceAddr 返回 Cursor API 服务监听地址。
func (server *Server) ServiceAddr() string { return server.config.ServiceAddr }
// UIURL 返回可在浏览器中打开的调试界面地址。
func (server *Server) UIURL() string {
return "http://" + browserAddress(server.config.ServiceAddr) + debugBasePath + "/"
}
// DatabasePath 返回捕获数据库路径。
func (server *Server) DatabasePath() string {
return server.config.DatabasePath
}
// newServiceHandler 创建单端口调试路由和固定上游流式转发。
func (server *Server) newServiceHandler() http.Handler {
reverseProxy := httputil.NewSingleHostReverseProxy(server.upstream)
reverseProxy.FlushInterval = -1
reverseProxy.ErrorLog = log.New(io.Discard, "", 0)
originalDirector := reverseProxy.Director
reverseProxy.Director = func(request *http.Request) {
originalDirector(request)
request.Host = server.upstream.Host
request.Header["X-Forwarded-For"] = nil
}
reverseProxy.Transport = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
DisableCompression: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
reverseProxy.ModifyResponse = server.captureResponse
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, upstreamErr error) {
server.failExchange(request, upstreamErr)
http.Error(writer, "Cursor API upstream unavailable", http.StatusBadGateway)
}
forwardHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
reverseProxy.ServeHTTP(writer, server.captureRequest(request))
})
debugHandler := http.StripPrefix(debugBasePath, server.newUIHandler())
mux := http.NewServeMux()
mux.Handle(debugBasePath+"/", debugHandler)
mux.HandleFunc(debugBasePath, func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, debugBasePath+"/", http.StatusTemporaryRedirect)
})
mux.Handle("/", forwardHandler)
return mux
}
+243
View File
@@ -0,0 +1,243 @@
// store.go 管理调试捕获的内存索引、SQLite 持久化和订阅通知。
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// exchangeStore 保存有限内存窗口以及可选的持久化数据库连接。
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
db *sql.DB
databasePath string
lastError string
}
// newExchangeStore 创建仅使用内存的捕获存储。
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
// newPersistentExchangeStore 创建 SQLite 持久化捕获存储并恢复最近记录。
func newPersistentExchangeStore(path string, max int) (*exchangeStore, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("SQLite 数据库路径不能为空")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("打开 SQLite 数据库失败: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := newExchangeStore(max)
store.db = db
store.databasePath = path
if err := store.initializeDatabase(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.backfillDecodedExchanges(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.loadRecent(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
// backfillDecodedExchanges 为旧记录补齐解码视图并写回数据库。
func (store *exchangeStore) backfillDecodedExchanges(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, "SELECT payload_json FROM exchanges")
if err != nil {
return fmt.Errorf("读取待回填的 SQLite 抓包记录失败: %w", err)
}
var exchanges []Exchange
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
_ = rows.Close()
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
_ = rows.Close()
return fmt.Errorf("解析待回填的 SQLite 抓包记录失败: %w", err)
}
if hydrateStoredExchange(&exchange) {
exchanges = append(exchanges, exchange)
}
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
if len(exchanges) == 0 {
return nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for index := range exchanges {
payload, marshalErr := json.Marshal(&exchanges[index])
if marshalErr != nil {
return marshalErr
}
if _, err := tx.ExecContext(ctx, `UPDATE exchanges SET payload_json = ?, conversation_id = ?,
request_id = ?, updated_at_ms = ? WHERE id = ?`, payload, exchanges[index].ConversationID,
exchanges[index].RequestID, time.Now().UnixMilli(), exchanges[index].ID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil
}
// initializeDatabase 创建调试器使用的 SQLite 表结构。
func (store *exchangeStore) initializeDatabase(ctx context.Context) error {
for _, statement := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA secure_delete = ON",
`CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY,
started_at_ms INTEGER NOT NULL,
conversation_id TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
request_bytes INTEGER NOT NULL DEFAULT 0,
response_bytes INTEGER NOT NULL DEFAULT 0,
payload_json BLOB NOT NULL,
updated_at_ms INTEGER NOT NULL
)`,
"CREATE INDEX IF NOT EXISTS exchanges_conversation_started_idx ON exchanges(conversation_id, started_at_ms DESC)",
"CREATE INDEX IF NOT EXISTS exchanges_request_idx ON exchanges(request_id)",
} {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("初始化 SQLite 数据库失败: %w", err)
}
}
return nil
}
// loadRecent 从数据库恢复内存窗口中的最新捕获。
func (store *exchangeStore) loadRecent(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, `SELECT payload_json, conversation_id
FROM exchanges ORDER BY started_at_ms DESC, id DESC LIMIT ?`, store.max)
if err != nil {
return fmt.Errorf("读取 SQLite 抓包记录失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var payload []byte
var conversationID string
if err := rows.Scan(&payload, &conversationID); err != nil {
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return fmt.Errorf("解析 SQLite 抓包记录失败: %w", err)
}
exchange.ConversationID = conversationID
store.exchanges[exchange.ID] = &exchange
store.order = append(store.order, exchange.ID)
}
return rows.Err()
}
// create 添加一条新的捕获并通知订阅者。
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.persistLocked(exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
// update 持久化修改并发布最终捕获快照。
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, true)
}
// updateTransient 只更新内存并发布流式过程快照。
func (store *exchangeStore) updateTransient(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, false)
}
// updateWithPersistence 在统一锁内完成修改、关联和可选持久化。
func (store *exchangeStore) updateWithPersistence(id string, apply func(*Exchange), persist bool) {
store.mu.Lock()
exchange := store.exchanges[id]
if exchange == nil && store.db != nil {
var err error
exchange, err = store.loadPersistedLocked(id)
if err != nil {
store.lastError = err.Error()
}
if exchange != nil {
store.exchanges[id] = exchange
store.order = append([]string{id}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
}
}
if exchange != nil {
previousRequestID := exchange.RequestID
previousConversationID := exchange.ConversationID
apply(exchange)
if exchange.RequestID != previousRequestID || exchange.ConversationID != previousConversationID {
store.associateConversationLocked(exchange)
}
if persist {
store.persistLocked(exchange)
}
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
// summaries 返回按时间倒序排列的请求摘要。
+354
View File
@@ -0,0 +1,354 @@
// store_queries.go 负责调试捕获记录的查询、持久化辅助和订阅通知。
package main
import (
"database/sql"
"encoding/json"
"sort"
"time"
)
// summaries 返回指定会话的捕获摘要列表。
func (store *exchangeStore) summaries(conversationID string) ([]ExchangeSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db != nil {
query := `SELECT payload_json, conversation_id FROM exchanges`
arguments := []any{}
if conversationID != "" {
query += " WHERE conversation_id = ?"
arguments = append(arguments, conversationID)
}
query += " ORDER BY started_at_ms DESC, id DESC"
rows, err := store.db.Query(query, arguments...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ExchangeSummary, 0)
for rows.Next() {
var payload []byte
var persistedConversationID string
if err := rows.Scan(&payload, &persistedConversationID); err != nil {
return nil, err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return nil, err
}
exchange.ConversationID = persistedConversationID
if current := store.exchanges[exchange.ID]; current != nil {
result = append(result, current.ExchangeSummary)
} else {
result = append(result, exchange.ExchangeSummary)
}
}
return result, rows.Err()
}
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result, nil
}
// get 返回内存或数据库中的完整捕获副本。
func (store *exchangeStore) get(id string) (Exchange, bool, error) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange != nil {
return cloneExchange(*exchange), true, nil
}
if store.db == nil {
return Exchange{}, false, nil
}
persisted, err := store.loadPersistedLocked(id)
if err != nil {
return Exchange{}, false, err
}
if persisted == nil {
return Exchange{}, false, nil
}
return *persisted, true, nil
}
// loadPersistedLocked 从 SQLite 读取单条捕获并在必要时解码回填。
func (store *exchangeStore) loadPersistedLocked(id string) (*Exchange, error) {
var payload []byte
var conversationID string
err := store.db.QueryRow("SELECT payload_json, conversation_id FROM exchanges WHERE id = ?", id).Scan(&payload, &conversationID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var persisted Exchange
if err := json.Unmarshal(payload, &persisted); err != nil {
return nil, err
}
persisted.ConversationID = conversationID
return &persisted, nil
}
// clear 清除数据库、内存索引和会话关联。
func (store *exchangeStore) clear() error {
store.mu.Lock()
var err error
if store.db != nil {
_, err = store.db.Exec("DELETE FROM exchanges")
if err != nil {
store.lastError = err.Error()
}
}
if err == nil {
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.lastError = ""
}
store.mu.Unlock()
if err == nil {
store.publish(storeEvent{Type: "cleared"})
}
return err
}
// conversations 按会话聚合持久化捕获统计。
func (store *exchangeStore) conversations() ([]ConversationSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db == nil {
groups := make(map[string]*ConversationSummary)
for _, exchange := range store.exchanges {
group := groups[exchange.ConversationID]
if group == nil {
group = &ConversationSummary{ConversationID: exchange.ConversationID}
groups[exchange.ConversationID] = group
}
group.ExchangeCount++
group.RequestBytes += exchange.RequestBytes
group.ResponseBytes += exchange.ResponseBytes
if exchange.StartedAt.After(group.LastStartedAt) {
group.LastStartedAt = exchange.StartedAt
}
}
result := make([]ConversationSummary, 0, len(groups))
for _, group := range groups {
result = append(result, *group)
}
sort.Slice(result, func(i, j int) bool { return result[i].LastStartedAt.After(result[j].LastStartedAt) })
return result, nil
}
rows, err := store.db.Query(`SELECT conversation_id, COUNT(*), MAX(started_at_ms),
COALESCE(SUM(request_bytes), 0), COALESCE(SUM(response_bytes), 0)
FROM exchanges GROUP BY conversation_id ORDER BY MAX(started_at_ms) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ConversationSummary, 0)
for rows.Next() {
var summary ConversationSummary
var startedAtMS int64
if err := rows.Scan(&summary.ConversationID, &summary.ExchangeCount, &startedAtMS, &summary.RequestBytes, &summary.ResponseBytes); err != nil {
return nil, err
}
summary.LastStartedAt = time.UnixMilli(startedAtMS)
result = append(result, summary)
}
return result, rows.Err()
}
// persistLocked 将当前捕获快照写入 SQLite。
func (store *exchangeStore) persistLocked(exchange *Exchange) {
if store.db == nil || exchange == nil {
return
}
payload, err := json.Marshal(exchange)
if err == nil {
_, err = store.db.Exec(`INSERT INTO exchanges (
id, started_at_ms, conversation_id, request_id, state, request_bytes,
response_bytes, payload_json, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
started_at_ms = excluded.started_at_ms,
conversation_id = excluded.conversation_id,
request_id = excluded.request_id,
state = excluded.state,
request_bytes = excluded.request_bytes,
response_bytes = excluded.response_bytes,
payload_json = excluded.payload_json,
updated_at_ms = excluded.updated_at_ms`,
exchange.ID, exchange.StartedAt.UnixMilli(), exchange.ConversationID,
exchange.RequestID, exchange.State, exchange.RequestBytes,
exchange.ResponseBytes, payload, time.Now().UnixMilli())
}
if err != nil {
store.lastError = err.Error()
} else {
store.lastError = ""
}
}
// associateConversationLocked 根据请求标识补齐会话关联。
func (store *exchangeStore) associateConversationLocked(exchange *Exchange) {
if exchange.RequestID == "" {
return
}
if exchange.ConversationID == "" {
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID != "" {
exchange.ConversationID = candidate.ConversationID
break
}
}
}
if exchange.ConversationID == "" && store.db != nil {
_ = store.db.QueryRow(`SELECT conversation_id FROM exchanges
WHERE request_id = ? AND conversation_id != ''
ORDER BY started_at_ms DESC LIMIT 1`, exchange.RequestID).Scan(&exchange.ConversationID)
}
if exchange.ConversationID == "" {
return
}
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID == "" {
candidate.ConversationID = exchange.ConversationID
store.persistLocked(candidate)
}
}
if store.db != nil {
if _, err := store.db.Exec(`UPDATE exchanges SET conversation_id = ?, updated_at_ms = ?
WHERE request_id = ? AND conversation_id = ''`, exchange.ConversationID, time.Now().UnixMilli(), exchange.RequestID); err != nil {
store.lastError = err.Error()
}
}
}
// maxNumericID 返回数据库中已使用的最大数字捕获编号。
func (store *exchangeStore) maxNumericID() uint64 {
store.mu.RLock()
defer store.mu.RUnlock()
var maximum uint64
if store.db != nil {
_ = store.db.QueryRow("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) FROM exchanges").Scan(&maximum)
}
return maximum
}
// close 关闭数据库连接并终止后续订阅通知。
func (store *exchangeStore) close() error {
store.mu.Lock()
defer store.mu.Unlock()
if store.db == nil {
return nil
}
err := store.db.Close()
store.db = nil
return err
}
// status 返回数据库路径和最近一次数据库错误。
func (store *exchangeStore) status() (string, string) {
store.mu.RLock()
defer store.mu.RUnlock()
return store.databasePath, store.lastError
}
// subscribe 注册一个捕获变化订阅者。
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
// publish 非阻塞地广播捕获变化事件。
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
// cloneExchange 深拷贝捕获及其请求响应载荷。
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
// clonePayload 深拷贝头信息和 Connect 帧切片。
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
// elapsedMS 计算从开始时间到当前时间的毫秒耗时。
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
// sortedHeaders 生成脱敏且按名称排序的请求头列表。
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
// isSensitiveHeader 判断请求头是否包含鉴权或隐私信息。
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
// httpCanonicalLower 将请求头名称规范化为小写形式。
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
+189
View File
@@ -0,0 +1,189 @@
// types.go 定义协议调试器配置、捕获详情和会话摘要模型。
package main
import (
"os"
"path/filepath"
"time"
)
// 默认值限制调试器只监听本机并约束内存捕获规模。
const (
defaultServiceAddr = "127.0.0.1:9090"
defaultUpstreamURL = "https://api2.cursor.sh"
debugBasePath = "/__debuger__"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
defaultDatabaseName = "cursor-proxy-debugger.db"
)
// Config 控制独立协议调试器的监听和存储限制。
type Config struct {
// ServiceAddr 是 Cursor API 调试服务监听地址。
ServiceAddr string
// MaxExchanges 是内存保留的最大请求数。
MaxExchanges int
// MaxCaptureBytes 是单向载荷保存上限。
MaxCaptureBytes int
// MaxFrames 是单条流保存的 Connect 帧上限。
MaxFrames int
// DatabasePath 是 SQLite 捕获数据库路径。
DatabasePath string
}
// normalized 补齐空值并拒绝无效的容量配置。
func (config Config) normalized() Config {
if config.ServiceAddr == "" {
config.ServiceAddr = defaultServiceAddr
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
if config.DatabasePath == "" {
config.DatabasePath = defaultDatabasePath()
}
return config
}
// defaultDatabasePath 返回当前用户配置目录下的默认数据库路径。
func defaultDatabasePath() string {
configDir, err := os.UserConfigDir()
if err != nil || configDir == "" {
return defaultDatabaseName
}
return filepath.Join(configDir, "cursor-byok", defaultDatabaseName)
}
// ExchangeSummary 是请求列表使用的紧凑捕获摘要。
type ExchangeSummary struct {
// ID 是进程内递增的捕获标识。
ID string `json:"id"`
// StartedAt 是请求开始时间。
StartedAt time.Time `json:"startedAt"`
// Method 是 HTTP 方法。
Method string `json:"method"`
// URL 是完整请求地址。
URL string `json:"url"`
// Host 是请求目标主机。
Host string `json:"host"`
// Path 是 RPC 或 HTTP 路径。
Path string `json:"path"`
// Status 是 HTTP 响应状态码。
Status int `json:"status"`
// State 是捕获处理阶段。
State string `json:"state"`
// DurationMS 是请求总耗时毫秒数。
DurationMS int64 `json:"durationMs"`
// RequestBytes 是完整请求体字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是完整响应体字节数。
ResponseBytes int64 `json:"responseBytes"`
// RequestID 是协议请求标识。
RequestID string `json:"requestId,omitempty"`
// ConversationID 是关联会话标识。
ConversationID string `json:"conversationId,omitempty"`
// RequestKind 是解码后的请求消息类型。
RequestKind string `json:"requestKind,omitempty"`
// ResponseKind 是解码后的响应消息类型。
ResponseKind string `json:"responseKind,omitempty"`
// FrameCount 是双向 Connect 帧总数。
FrameCount int `json:"frameCount"`
// Error 是转发或解码错误。
Error string `json:"error,omitempty"`
}
// Exchange 保存调试界面展示的请求和响应详情。
type Exchange struct {
ExchangeSummary
// Request 是请求方向载荷。
Request Payload `json:"request"`
// Response 是响应方向载荷。
Response Payload `json:"response"`
}
// Payload 保存请求头、原始副本、解码正文和协议帧。
type Payload struct {
// Headers 是脱敏且排序稳定的 HTTP 请求头。
Headers []Header `json:"headers"`
// ContentType 是规范化媒体类型。
ContentType string `json:"contentType,omitempty"`
// ContentCodec 是内容压缩算法。
ContentCodec string `json:"contentCodec,omitempty"`
// Size 是完整方向载荷字节数。
Size int64 `json:"size"`
// RawHex 是受限原始副本的十六进制文本。
RawHex string `json:"rawHex,omitempty"`
// RawTruncated 表示原始副本达到保存上限。
RawTruncated bool `json:"rawTruncated,omitempty"`
// DecodedJSON 是格式化后的结构化正文。
DecodedJSON string `json:"decodedJson,omitempty"`
// DecodedLang 是前端编辑器使用的语言标识。
DecodedLang string `json:"decodedLanguage,omitempty"`
// DecodeError 是不影响转发的解码错误。
DecodeError string `json:"decodeError,omitempty"`
// Frames 是 Connect 流的逐帧视图。
Frames []FrameView `json:"frames,omitempty"`
}
// Header 是排序稳定的 HTTP 请求头键值对。
type Header struct {
// Name 是请求头名称。
Name string `json:"name"`
// Value 是已脱敏的请求头值。
Value string `json:"value"`
}
// FrameView 描述一条 Connect 流式信封。
type FrameView struct {
// Index 是帧在当前方向的序号。
Index int `json:"index"`
// Flags 是 Connect 原始标志位。
Flags uint8 `json:"flags"`
// Length 是解压前帧载荷长度。
Length int `json:"length"`
// Compressed 表示帧载荷使用压缩。
Compressed bool `json:"compressed"`
// EndStream 表示帧携带流结束标志。
EndStream bool `json:"endStream"`
// Kind 是解码后的业务消息类型。
Kind string `json:"kind,omitempty"`
// MessageType 是 protobuf 完整消息名。
MessageType string `json:"messageType,omitempty"`
// RequestID 是帧中解析出的请求标识。
RequestID string `json:"requestId,omitempty"`
// JSON 是 protobuf 的 JSON 视图。
JSON string `json:"json,omitempty"`
// RawHex 是无法解码时保留的载荷文本。
RawHex string `json:"rawHex,omitempty"`
// Error 是当前帧的解压或解码错误。
Error string `json:"error,omitempty"`
}
// storeEvent 是 SSE 通知使用的最小变化事件。
type storeEvent struct {
// Type 是捕获记录变化类型。
Type string `json:"type"`
// ID 是关联捕获标识。
ID string `json:"id,omitempty"`
}
// ConversationSummary 描述按会话聚合的持久化流量。
type ConversationSummary struct {
// ConversationID 是会话稳定标识。
ConversationID string `json:"conversationId"`
// ExchangeCount 是会话捕获记录数。
ExchangeCount int `json:"exchangeCount"`
// LastStartedAt 是会话最近请求时间。
LastStartedAt time.Time `json:"lastStartedAt"`
// RequestBytes 是会话累计请求字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是会话累计响应字节数。
ResponseBytes int64 `json:"responseBytes"`
}
+141
View File
@@ -0,0 +1,141 @@
// web.go 提供调试器只读 API、SSE 更新流和内嵌静态页面。
package main
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
)
// webAssets 保存无需外部文件即可启动的调试页面资源。
//
//go:embed web/*
var webAssets embed.FS
// newUIHandler 注册只绑定本机界面的调试 API 和静态资源。
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("GET /api/conversations", server.handleConversationList)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
// handleStatus 返回监听地址、固定上游和数据库状态。
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
databasePath, databaseError := server.store.status()
writeJSON(writer, http.StatusOK, map[string]any{
"serviceAddr": server.config.ServiceAddr,
"debugPath": debugBasePath + "/",
"upstreamURL": server.upstream.String(),
"running": true,
"databasePath": databasePath,
"databaseError": databaseError,
})
}
// handleExchangeList 按可选会话标识列出请求摘要。
func (server *Server) handleExchangeList(writer http.ResponseWriter, request *http.Request) {
conversationID := strings.TrimSpace(request.URL.Query().Get("conversation_id"))
summaries, err := server.store.summaries(conversationID)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, summaries)
}
// handleExchangeDetail 返回单条请求的完整捕获详情。
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok, err := server.store.get(id)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
// handleClearExchanges 清除内存和 SQLite 中的捕获记录。
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
if err := server.clearExchanges(); err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writer.WriteHeader(http.StatusNoContent)
}
// handleConversationList 返回持久化流量的会话分组。
func (server *Server) handleConversationList(writer http.ResponseWriter, _ *http.Request) {
conversations, err := server.store.conversations()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, conversations)
}
// handleEvents 通过 SSE 推送捕获记录变化和保活心跳。
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
// writeJSON 写入统一 JSON 响应。
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
// securityHeaders 为本地调试页面添加最小浏览器安全策略。
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; worker-src 'self' blob:")
next.ServeHTTP(writer, request)
})
}
+500
View File
@@ -0,0 +1,500 @@
// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
import { bindEvents, renderPauseState } from "./app_events.js";
import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js";
const monacoReady = loadMonaco();
const editorSlots = {
request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
};
const state = {
status: null,
exchanges: [],
conversations: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
conversationId: "",
endpoint: "all",
bidiMessageKinds: new Set(),
showOptions: false,
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "body",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
serviceAddress: document.querySelector("#service-address"),
upstreamURL: document.querySelector("#upstream-url"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
conversationSelect: document.querySelector("#conversation-select"),
endpointFilter: document.querySelector("#endpoint-filter"),
bidiMessageFilter: document.querySelector("#bidi-message-filter"),
bidiMessageOptions: document.querySelector("#bidi-message-options"),
showOptionsCheckbox: document.querySelector("#show-options-checkbox"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`;
elements.upstreamURL.textContent = state.status.upstreamURL;
elements.showOptionsCheckbox.checked = state.showOptions;
}
async function refreshList() {
const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : "";
[state.exchanges, state.conversations] = await Promise.all([
fetchJSON(`api/exchanges${query}`),
fetchJSON("api/conversations"),
]);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
function renderConversationOptions() {
const selected = state.conversationId;
const options = [`<option value="">${escapeHTML(t("filters.allConversations"))}</option>`];
for (const conversation of state.conversations) {
if (!conversation.conversationId) continue;
const label = `${conversation.conversationId} (${conversation.exchangeCount})`;
options.push(`<option value="${escapeHTML(conversation.conversationId)}">${escapeHTML(label)}</option>`);
}
elements.conversationSelect.innerHTML = options.join("");
elements.conversationSelect.value = selected;
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false;
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 === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) 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)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function renderBidiMessageFilter() {
const visible = state.endpoint === "bidiappend";
elements.bidiMessageFilter.hidden = !visible;
if (!visible) elements.bidiMessageFilter.open = false;
const availableKinds = state.exchanges
.filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind)
.map((item) => item.requestKind);
const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right));
elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size
? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size })
: t("filters.allMessageTypes");
elements.bidiMessageOptions.innerHTML = [
`<label class="multi-select-option all-option"><input type="checkbox" value=""${state.bidiMessageKinds.size === 0 ? " checked" : ""}><span>${escapeHTML(t("filters.allMessageTypes"))}</span></label>`,
...kinds.map((kind) => `<label class="multi-select-option"><input type="checkbox" value="${escapeHTML(kind)}"${state.bidiMessageKinds.has(kind) ? " checked" : ""}><span title="${escapeHTML(kind)}">${escapeHTML(kind)}</span></label>`),
].join("");
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
const groups = new Map();
for (const item of exchanges) {
const conversationID = item.conversationId || "";
if (!groups.has(conversationID)) groups.set(conversationID, []);
groups.get(conversationID).push(item);
}
elements.requestList.innerHTML = [...groups.entries()]
.map(([conversationID, items]) => {
const label = conversationID || t("groups.unassigned");
const header = `<tr class="conversation-group"><td colspan="9"><span>${escapeHTML(t("groups.conversation"))}</span><code title="${escapeHTML(label)}">${escapeHTML(label)}</code><strong>${items.length}</strong></td></tr>`;
const rows = items.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
}).join("");
return header + rows;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
disposeEditor("request");
disposeEditor("response");
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
renderPayload("request", item.request, state.tabs.request);
renderPayload("response", item.response, state.tabs.response);
}
function renderDetailError(error) {
disposeEditor("request");
disposeEditor("response");
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(side, payload, tab) {
const container = elements[`${side}Content`];
if (!payload) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`);
return;
}
if (tab === "headers") {
renderStaticPayload(side, renderHeaders(payload.headers));
return;
}
if (tab === "raw") {
if (!payload.rawHex) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noRaw"))}</div>`);
return;
}
renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated));
return;
}
if (tab === "frames") {
const document = frameEditorDocument(payload.frames);
if (!document) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`);
return;
}
renderEditorPayload(side, document, "json", "");
return;
}
if (payload.decodedJson) {
renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`);
return;
}
if (payload.frames?.length) {
renderEditorPayload(side, frameEditorDocument(payload.frames), "json", "");
return;
}
if (payload.decodeError) {
renderStaticPayload(side, `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`);
return;
}
container.classList.remove("editor-active");
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`);
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function frameEditorDocument(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return "";
const normalized = items.map((frame) => {
let message = frame.rawHex || null;
if (frame.json) {
try {
message = JSON.parse(frame.json);
} catch {
message = frame.json;
}
}
return {
index: frame.index,
kind: frame.kind || frame.messageType || t("notices.unknown"),
messageType: frame.messageType || undefined,
flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`,
length: frame.length,
compressed: Boolean(frame.compressed),
endStream: Boolean(frame.endStream),
requestId: frame.requestId || undefined,
error: frame.error || undefined,
message,
};
});
return JSON.stringify(normalized, null, 2);
}
function renderStaticPayload(side, markup) {
disposeEditor(side);
const container = elements[`${side}Content`];
container.classList.remove("editor-active");
container.innerHTML = markup;
}
function renderEditorPayload(side, value, language, notices) {
const container = elements[`${side}Content`];
const slot = editorSlots[side];
slot.value = value;
slot.language = language;
container.classList.add("editor-active");
let host = container.querySelector(".editor-host");
if (!host || slot.host !== host) {
disposeEditor(side);
slot.value = value;
slot.language = language;
container.innerHTML = `<div class="editor-host"><pre class="editor-fallback">${escapeHTML(value)}</pre></div><div class="editor-notices">${notices}</div>`;
host = container.querySelector(".editor-host");
void createEditor(side, host, value, language);
return;
}
container.querySelector(".editor-notices").innerHTML = notices;
const fallback = host.querySelector(".editor-fallback");
if (fallback) fallback.textContent = value;
updateEditor(slot, value, language);
}
async function createEditor(side, host, value, language) {
const slot = editorSlots[side];
const token = ++slot.token;
slot.host = host;
try {
const monaco = await monacoReady;
if (token !== slot.token || !host.isConnected) return;
host.textContent = "";
const model = monaco.editor.createModel(slot.value || value, slot.language || language);
const editor = monaco.editor.create(host, {
model,
theme: "vs-dark",
readOnly: true,
domReadOnly: true,
automaticLayout: true,
fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace",
fontSize: 12,
lineHeight: 19,
minimap: { enabled: false },
glyphMargin: false,
folding: true,
lineNumbersMinChars: 3,
overviewRulerLanes: 0,
overviewRulerBorder: false,
renderLineHighlight: "none",
scrollBeyondLastLine: false,
smoothScrolling: true,
wordWrap: "off",
padding: { top: 8, bottom: 16 },
stickyScroll: { enabled: false },
contextmenu: true,
});
slot.editor = editor;
slot.model = model;
slot.host = host;
} catch {
// Monaco 初始化失败时保留文本回退视图。
}
}
function updateEditor(slot, value, language) {
if (!slot.editor || !slot.model) return;
const monaco = window.monaco;
if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language);
if (slot.model.getValue() === value) return;
const viewState = slot.editor.saveViewState();
slot.model.setValue(value);
if (viewState) slot.editor.restoreViewState(viewState);
}
function disposeEditor(side) {
const slot = editorSlots[side];
slot.token += 1;
slot.editor?.dispose();
slot.model?.dispose();
slot.editor = null;
slot.model = null;
slot.host = null;
slot.value = "";
slot.language = "plaintext";
}
function loadMonaco() {
return new Promise((resolve, reject) => {
const amdRequire = window.require;
if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") {
reject(new Error("Monaco loader is unavailable"));
return;
}
amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } });
amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
});
}
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState(state, elements);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
renderDetail();
}
bindEvents({
state,
elements,
fetchJSON,
refreshList,
refreshDetail,
renderList,
renderDetail,
renderBidiMessageFilter,
setConnectionState,
applyLocale,
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
+159
View File
@@ -0,0 +1,159 @@
// app_events.js 绑定调试器筛选、详情、暂停和布局交互事件。
import { t } from "./i18n.js";
import { currentCopyText } from "./view_helpers.js";
// renderPauseState 更新暂停按钮的文本和可访问性属性。
export function renderPauseState(state, elements) {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
// bindEvents 绑定调试器页面的筛选、详情、暂停和布局交互。
export function bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale }) {
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.conversationSelect.addEventListener("change", async (event) => {
state.conversationId = event.target.value;
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderBidiMessageFilter();
renderList();
});
elements.bidiMessageOptions.addEventListener("change", (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) return;
if (!checkbox.value) {
state.bidiMessageKinds.clear();
} else if (checkbox.checked) {
state.bidiMessageKinds.add(checkbox.value);
} else {
state.bidiMessageKinds.delete(checkbox.value);
}
renderBidiMessageFilter();
elements.bidiMessageFilter.open = true;
renderList();
});
document.addEventListener("click", (event) => {
if (!elements.bidiMessageFilter.contains(event.target)) elements.bidiMessageFilter.open = false;
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide, state);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState(state, elements);
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.showOptionsCheckbox.addEventListener("change", (event) => {
state.showOptions = event.target.checked;
if (!state.showOptions && String(state.selected?.method || "").toUpperCase() === "OPTIONS") {
state.selectedId = null;
state.selected = null;
renderDetail();
}
renderList();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
state.conversationId = "";
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
}
+189
View File
@@ -0,0 +1,189 @@
// i18n.js 提供协议调试器中英文消息和运行时语言切换。
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "服务运行中",
"status.stopped": "服务已停止",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.conversation": "按 Conversation ID 查询",
"filters.allConversations": "全部会话",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.allMessageTypes": "全部消息类型",
"filters.selectedMessageTypes": "已选 {count} 种消息",
"filters.showOptions": "显示 OPTIONS",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"groups.conversation": "会话",
"groups.unassigned": "未关联会话",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Service running",
"status.stopped": "Service stopped",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.conversation": "Query by Conversation ID",
"filters.allConversations": "All conversations",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.allMessageTypes": "All message types",
"filters.selectedMessageTypes": "{count} message types",
"filters.showOptions": "Show OPTIONS",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"groups.conversation": "Conversation",
"groups.unassigned": "Unassigned",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
+139
View File
@@ -0,0 +1,139 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="service-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<label class="conversation-filter">
<span class="visually-hidden" data-i18n="filters.conversation">会话</span>
<select id="conversation-select" aria-label="按 Conversation ID 查询" data-i18n-aria-label="filters.conversation">
<option value="" data-i18n="filters.allConversations">全部会话</option>
</select>
</label>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
</div>
<details id="bidi-message-filter" class="multi-select-filter" hidden>
<summary data-i18n="filters.allMessageTypes">全部消息类型</summary>
<div id="bidi-message-options" class="multi-select-menu"></div>
</details>
<label class="checkbox-control">
<input id="show-options-checkbox" type="checkbox" />
<span data-i18n="filters.showOptions">显示 OPTIONS</span>
</label>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="upstream-url"></span>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs/loader.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
/* styles.css 组合调试器的基础、控件、详情和响应式样式。 */
@import url("./styles_base.css");
@import url("./styles_controls.css");
@import url("./styles_detail.css");
@import url("./styles_responsive.css");
+445
View File
@@ -0,0 +1,445 @@
/* styles.css 定义协议调试器的暗色布局、组件和响应式样式。 */
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
flex-wrap: wrap;
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.conversation-filter {
flex: 0 1 300px;
min-width: 190px;
}
.conversation-filter select {
width: 100%;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 28px 0 9px;
background: #191b1b;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 7px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 9px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.checkbox-control:has(input:checked) {
border-color: #376858;
background: var(--accent-soft);
color: #bce8d9;
}
.checkbox-control input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-filter {
position: relative;
flex: 0 0 170px;
height: 31px;
color: var(--text);
font-size: 12px;
}
.multi-select-filter[hidden] {
display: none;
}
.multi-select-filter summary {
overflow: hidden;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 7px 28px 0 9px;
background: #191b1b;
cursor: pointer;
list-style: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-filter summary::-webkit-details-marker {
display: none;
}
.multi-select-filter summary::after {
position: absolute;
top: 10px;
right: 10px;
content: "";
border: 4px solid transparent;
border-top-color: var(--muted);
}
.multi-select-filter[open] summary {
border-color: var(--border-strong);
}
.multi-select-menu {
position: absolute;
z-index: 20;
top: 35px;
right: 0;
overflow: auto;
width: 260px;
max-height: 320px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 4px;
background: var(--surface-2);
box-shadow: 0 8px 24px rgb(0 0 0 / 34%);
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
height: 29px;
border-radius: 3px;
padding: 0 7px;
cursor: pointer;
}
.multi-select-option:hover {
background: var(--surface-3);
}
.multi-select-option input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-option span {
overflow: hidden;
font-family: var(--mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-option.all-option {
border-bottom: 1px solid var(--border);
border-radius: 0;
margin-bottom: 3px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
+189
View File
@@ -0,0 +1,189 @@
/* styles_controls.css 定义调试器筛选栏、请求列表和基础交互控件。 */
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table tbody tr.conversation-group,
.request-table tbody tr.conversation-group:hover {
cursor: default;
background: #202323;
}
.request-table tbody tr.conversation-group td {
height: 28px;
border-top: 1px solid var(--border-strong);
color: var(--muted);
}
.conversation-group span {
margin-right: 8px;
color: #9ba19e;
}
.conversation-group code {
color: #73becb;
}
.conversation-group strong {
margin-left: 8px;
color: #78817d;
font-size: 10px;
font-weight: 500;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
+287
View File
@@ -0,0 +1,287 @@
/* styles_detail.css 定义请求详情、载荷面板和状态提示布局。 */
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.panel-content.editor-active {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.editor-host {
min-width: 0;
min-height: 0;
background: #1e1e1e;
}
.editor-fallback {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 10px 14px 24px;
overflow: auto;
color: #d4d4d4;
font: 12px/1.58 var(--mono);
white-space: pre;
}
.editor-notices:empty {
display: none;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
+195
View File
@@ -0,0 +1,195 @@
/* styles_responsive.css 定义调试器在窄屏下的响应式布局。 */
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px auto minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#upstream-url {
display: none;
}
}
+71
View File
@@ -0,0 +1,71 @@
// view_helpers.js 提供调试器界面使用的格式化、转义和复制文本辅助函数。
import { t } from "./i18n.js";
// renderDecodeError 将解码错误转换为安全的提示片段。
export function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
// renderTruncated 生成正文被截断时的提示片段。
export function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
// formatState 将捕获状态转换为当前语言的展示文本。
export function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
// currentCopyText 根据当前标签页提取可复制的载荷文本。
export function currentCopyText(side, state) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
// formatHex 将十六进制载荷按行格式化为调试视图。
export function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
// formatBytes 将字节数格式化为人类可读的单位。
export function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// formatDuration 将毫秒耗时格式化为人类可读的单位。
export function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
// escapeHTML 转义用户或网络输入,避免插入界面时形成 HTML。
export function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
Submodule cursor-proxy-server deleted from 9ab33eb5ad