refactor: rebuild desktop app with Tauri

This commit is contained in:
leookun
2026-08-24 02:49:00 +08:00
parent a3ec2a0dfc
commit 4053a7fb20
808 changed files with 87359 additions and 181491 deletions
+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 10px;
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;
}
@@ -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);
}
@@ -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 10px;
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;
}
@@ -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;
}
}
@@ -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;");
}
@@ -0,0 +1,192 @@
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
package main
import "testing"
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
if err != nil {
t.Fatalf("parse shorthand T: %v", err)
}
if field.T != "T" {
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
}
}
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
n.d(t,{KS:()=>T,_B:()=>r});
var r;
class T {}
T.typeName="agent.v1.AgentClientMessage";
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
},
2:(e,t,n)=>{
var r=n(1);
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := []Message{{
TypeName: "agent.v1.AgentClientMessage",
VarName: "T",
InternalName: "T",
Package: "agent.v1",
Pos: 35,
ModuleStart: moduleStartForPos(moduleStarts, 35),
}}
enums := []Enum{{
TypeName: "agent.v1.DiagnosticSeverity",
VarName: "r",
Package: "agent.v1",
Pos: 100,
ModuleStart: moduleStartForPos(moduleStarts, 100),
}}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
if !ok {
t.Fatal("expected webpack export alias to resolve")
}
if typeName != "agent.v1.AgentClientMessage" {
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
}
}
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
"nt": {
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
},
}}
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
if !ok {
t.Fatal("expected cross-package message type to resolve")
}
if typeName != "git_forge.v1.GetTagResponse" {
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
}
}
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
const bundle = `
42:(e,t,n)=>{
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
enums := extractEnums(bundle, moduleStarts)
services := extractServices(bundle, moduleStarts)
if len(messages) != 3 {
t.Fatalf("extracted %d messages, want 3", len(messages))
}
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
}
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
t.Fatalf("unexpected enums: %#v", enums)
}
if len(services) != 1 || len(services[0].Methods) != 2 {
t.Fatalf("unexpected services: %#v", services)
}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
method := services[0].Methods[0]
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
}
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
}
}
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
var Timestamp=class TimestampMessage extends Base{};
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
if !ok || typeName != "google.protobuf.Timestamp" {
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
}
}
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
const bundle = `
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
var Service={typeName:"aiserver.v1.TestService",methods:{}};
`
messages := []Message{{TypeName: "aiserver.v1.Request"}}
services := []Service{{TypeName: "aiserver.v1.TestService"}}
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
if declared != 3 || extracted != 2 {
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
}
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
t.Fatalf("unexpected missing declarations: %#v", missing)
}
}
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
services := extractServices(bundle, nil)
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
t.Fatalf("unexpected services: %#v", services)
}
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
t.Fatalf("unexpected methods: %#v", services[0].Methods)
}
}
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
primary := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
}
supplemental := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
}
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
if len(merged) != 2 {
t.Fatalf("merged %d messages, want 2", len(merged))
}
if merged[0].Fields[0].Name != "primary" {
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
}
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
t.Fatalf("supplemental-only type missing: %#v", merged)
}
}
+341
View File
@@ -0,0 +1,341 @@
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
package main
import (
"fmt"
"os"
)
// generateProtos 按协议包聚合声明并生成对应文件。
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
os.MkdirAll(outputDir, 0755)
// 按协议包聚合声明。
packages := make(map[string]struct {
messages []Message
enums []Enum
services []Service
})
for _, msg := range messages {
pkg := packages[msg.Package]
pkg.messages = append(pkg.messages, msg)
packages[msg.Package] = pkg
}
for _, enum := range enums {
pkg := packages[enum.Package]
pkg.enums = append(pkg.enums, enum)
packages[enum.Package] = pkg
}
for _, svc := range services {
pkg := packages[svc.Package]
pkg.services = append(pkg.services, svc)
packages[svc.Package] = pkg
}
// 建立跨包复制使用的全局类型索引。
allMessages := make(map[string]*Message)
allEnums := make(map[string]*Enum)
for pkgName, pkg := range packages {
if isGooglePkg(pkgName) {
continue
}
for i := range pkg.messages {
msg := &pkg.messages[i]
allMessages[msg.TypeName] = msg
}
for i := range pkg.enums {
enum := &pkg.enums[i]
allEnums[enum.TypeName] = enum
}
}
// 每轮生成前重置已复制类型索引。
copiedTypes = make(map[string]map[string]string)
for pkgName, pkg := range packages {
// Google 标准包直接使用官方协议文件。
if isGooglePkg(pkgName) {
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
continue
}
// 把当前包引用的外部类型复制到本地。
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
}
}
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
func copyAllExternalTypes(pkgName string, pkg struct {
messages []Message
enums []Enum
services []Service
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
messages []Message
enums []Enum
services []Service
} {
if copiedTypes[pkgName] == nil {
copiedTypes[pkgName] = make(map[string]string)
}
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
localTypes := make(map[string]bool)
for _, msg := range pkg.messages {
localTypes[msg.ShortName] = true
// 空来源名表示该类型原本就在当前包。
if copiedTypes[pkgName][msg.ShortName] == "" {
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
}
}
for _, enum := range pkg.enums {
localTypes[enum.ShortName] = true
if copiedTypes[pkgName][enum.ShortName] == "" {
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
}
}
// 结果先保留当前包原始声明。
result := struct {
messages []Message
enums []Enum
services []Service
}{
messages: append([]Message{}, pkg.messages...),
enums: append([]Enum{}, pkg.enums...),
services: pkg.services,
}
totalCopied := 0
// 持续迭代,直到不再发现新的外部依赖。
for round := 1; ; round++ {
// 收集当前消息中的外部类型引用。
neededTypes := make(map[string]bool)
for _, msg := range result.messages {
preferredPkg, _ := parseTypeName(msg.TypeName)
for _, f := range msg.Fields {
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
}
}
for _, svc := range result.services {
for _, m := range svc.Methods {
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
}
}
// 复制本轮新增依赖类型。
copiedThisRound := 0
for typeName := range neededTypes {
refPkg, shortName := parseTypeName(typeName)
if refPkg == pkgName || isGooglePkg(refPkg) {
continue
}
// 已存在于本地时无需重复复制。
if localTypes[shortName] {
continue
}
// 复制消息声明。
if msg, ok := allMessages[typeName]; ok {
msgCopy := *msg
msgCopy.Package = pkgName
// 保留原始完整类型名,用于生成来源注释。
result.messages = append(result.messages, msgCopy)
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
} else if enum, ok := allEnums[typeName]; ok {
// 复制枚举声明。
enumCopy := *enum
enumCopy.Package = pkgName
result.enums = append(result.enums, enumCopy)
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
} else {
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
}
}
totalCopied += copiedThisRound
if copiedThisRound == 0 {
break // 没有新增依赖时结束迭代。
}
if round > 20 {
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
break
}
}
if totalCopied > 0 {
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
}
return result
}
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
type refWithKind struct {
ref string
kind string
}
var refs []refWithKind
if f.Kind == "message" || f.Kind == "enum" {
if v, ok := f.T.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
}
}
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if v, ok := f.MapValueT.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
}
}
for _, item := range refs {
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
if !ok {
continue
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
continue
}
// 已在当前包中的类型无需收集。
if localTypes[shortName] {
continue
}
neededTypes[typeName] = true
}
}
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
return
}
if localTypes[shortName] {
return
}
neededTypes[typeName] = true
}
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
var copiedTypes = make(map[string]map[string]string)
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
type TypeNode struct {
// Name 是当前嵌套层级的类型名。
Name string
// Message 保存当前节点的消息声明。
Message *Message
// Enum 保存当前节点的枚举声明。
Enum *Enum
// Children 保存下一层嵌套类型。
Children map[string]*TypeNode
}
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
imports := make(map[string]bool)
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
// 仅导入 Google 标准类型。
if refPkg == "google.protobuf" {
var importFile string
switch shortName {
case "Struct", "Value", "ListValue", "NullValue":
importFile = "google/protobuf/struct.proto"
case "Timestamp":
importFile = "google/protobuf/timestamp.proto"
case "Duration":
importFile = "google/protobuf/duration.proto"
case "Any":
importFile = "google/protobuf/any.proto"
case "Empty":
importFile = "google/protobuf/empty.proto"
case "FieldMask":
importFile = "google/protobuf/field_mask.proto"
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
importFile = "google/protobuf/wrappers.proto"
default:
importFile = "google/protobuf/descriptor.proto"
}
imports[importFile] = true
} else if refPkg == "google.rpc" {
var importFile string
switch shortName {
case "Status":
importFile = "google/rpc/status.proto"
case "Code":
importFile = "google/rpc/code.proto"
default:
importFile = "google/rpc/status.proto"
}
imports[importFile] = true
}
}
for _, msg := range messages {
for _, f := range msg.Fields {
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
}
}
// map 值类型也可能引用标准包。
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if ref, ok := f.MapValueT.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
}
}
}
}
for _, svc := range services {
for _, m := range svc.Methods {
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
}
}
return imports
}
+134
View File
@@ -0,0 +1,134 @@
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// inputPaths 支持命令行重复传入 bundle 路径。
type inputPaths []string
// String 返回已经登记的输入路径列表。
func (paths *inputPaths) String() string {
return fmt.Sprint([]string(*paths))
}
// Set 追加一个去除空白后的输入路径。
func (paths *inputPaths) Set(value string) error {
*paths = append(*paths, value)
return nil
}
// bailIf 在不可恢复错误时打印信息并退出。
func bailIf(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// findPrettier 定位可用的 prettier 命令。
func findPrettier() (string, error) {
// 尝试常见的 prettier 命令名
names := []string{"prettier", "prettier.cmd", "npx"}
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
}
// main 解析参数、保护原始输入并执行协议提取。
func main() {
// 命令行参数
var inputs inputPaths
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
flag.Parse()
// 如果没有 -input 参数,尝试从位置参数获取
if len(inputs) == 0 && flag.NArg() > 0 {
inputs = append(inputs, flag.Args()...)
}
if len(inputs) == 0 {
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
fmt.Fprintln(os.Stderr, "\nExample:")
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
os.Exit(1)
}
for _, inputPath := range inputs {
info, err := os.Stat(inputPath)
bailIf(err)
if info.IsDir() {
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
}
}
// 设置输出目录
if *outputDir == "" {
wd, err := os.Getwd()
bailIf(err)
*outputDir = filepath.Join(wd, "cursor_proto")
}
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
tempFileNames := make([]string, 0, len(inputs))
for _, inputPath := range inputs {
originalFile, err := os.Open(inputPath)
bailIf(err)
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
bailIf(err)
_, err = io.Copy(tempFile, originalFile)
bailIf(err)
bailIf(originalFile.Close())
bailIf(tempFile.Close())
tempFileNames = append(tempFileNames, tempFile.Name())
fmt.Printf("Source: %s\n", inputPath)
}
if *skipFormat {
fmt.Println("Skipping formatting (--skip-format)")
} else if prettierBin, err := findPrettier(); err != nil {
fmt.Printf("Warning: %v\n", err)
fmt.Println("Skipping formatting, extraction may be less accurate...")
} else {
fmt.Println("Formatting source bundles (this may take a while)...")
for _, tempFileName := range tempFileNames {
var prettierCmd *exec.Cmd
if filepath.Base(prettierBin) == "npx" {
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
} else {
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
}
out, formatErr := prettierCmd.CombinedOutput()
if formatErr != nil {
fmt.Printf("Prettier output: %s\n", string(out))
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
}
}
}
// 运行提取器
fmt.Println("Extracting Proto definitions...")
SetStrictMode(*strict)
ExtractProtosFromFiles(tempFileNames, *outputDir)
for _, tempFileName := range tempFileNames {
_ = os.Remove(tempFileName)
}
fmt.Printf("\nOutput directory: %s\n", *outputDir)
}
+343
View File
@@ -0,0 +1,343 @@
// messages.go 解析消息声明、字段数组和字段类型信息。
package main
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
// extractMessages 从多种 bundle 语法中提取消息声明。
func extractMessages(text string, moduleStarts []int) []Message {
var messages []Message
messageExists := func(typeName, varName string) bool {
for _, existing := range messages {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 形式一:变量引用继承基类并在类体中声明 typeName 和 fields。
// 先找所有 "变量名 = class 内部类名" 定义
// JS 变量名可以包含 $ 符号,如 B$e, qg 等
// 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个
classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`)
classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1)
// 从任意包的 this.typeName 字段读取完整类型名。
typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`)
// 从 this.fields 的 newFieldList 回调读取字段数组。
fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
for _, classMatch := range classMatches {
varName := text[classMatch[2]:classMatch[3]]
internalName := text[classMatch[4]:classMatch[5]]
classStart := classMatch[0]
// 找到类的结束位置(匹配大括号)
classEnd := findClassEnd(text, classMatch[1]-1)
if classEnd == -1 {
continue
}
classBody := text[classStart:classEnd]
// 在类体内查找 typeName
typeMatch := typeNameRe.FindStringSubmatch(classBody)
if typeMatch == nil {
continue
}
typeName := typeMatch[1]
// 在类体内查找 fields
fieldsMatch := fieldsRe.FindStringIndex(classBody)
if fieldsMatch == nil {
continue
}
// 找到 fields 数组的开始位置
bracketPos := classStart + fieldsMatch[1] - 1
fields := extractFieldArray(text, bracketPos)
pkg, shortName := parseTypeName(typeName)
msg := Message{
TypeName: typeName,
VarName: varName,
InternalName: internalName,
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: classStart,
ModuleStart: moduleStartForPos(moduleStarts, classStart),
}
messages = append(messages, msg)
}
// 形式二:匹配转译或压缩 bundle 中连续赋值的消息声明。
// 例如 i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}])。
assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range assignmentMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
// 跳过已经由类体形式提取的重复消息。
if messageExists(typeName, varName) {
continue
}
// 正则停在左方括号之前,从匹配尾部定位数组起点。
start := m[1] - 1
if start < 0 || start >= len(text) || text[start] != '[' {
continue
}
fields := extractFieldArray(text, start)
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
InternalName: "",
Fields: fields,
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 形式三:匹配现代 @bufbuild/protobuf 工厂调用。
// 例如 Req=A.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{...}])。
messageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\(\s*\)\s*=>\s*\[`)
factoryMatches := messageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range factoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
// 空消息直接传字段数组,不使用延迟回调。
// 例如 Res=A.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[])。
emptyMessageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
emptyFactoryMatches := emptyMessageFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, m := range emptyFactoryMatches {
varName := text[m[2]:m[3]]
typeName := text[m[4]:m[5]]
if messageExists(typeName, varName) {
continue
}
bracketStart := m[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
messages = append(messages, Message{
TypeName: typeName,
VarName: varName,
Fields: extractFieldArray(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: m[0],
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
})
}
return messages
}
// findClassEnd 查找类定义的配对右花括号。
func findClassEnd(text string, openBrace int) int {
depth := 0
for i := openBrace; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractFieldArray 从左方括号位置解析完整字段数组。
func extractFieldArray(text string, start int) []Field {
// 查找字段数组的配对右方括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
// 按每个花括号块解析独立字段对象。
var fields []Field
// 依次查找字段对象。
fieldObjects := extractFieldObjects(arrayText)
for _, fieldObj := range fieldObjects {
field, parseErr := parseFieldObject(fieldObj)
if parseErr != nil {
activeDiagnostics.addSkippedField(fieldObj, parseErr)
continue
}
activeDiagnostics.addParsedField()
fields = append(fields, *field)
}
return fields
}
// extractFieldObjects 从数组文本中提取独立字段对象。
func extractFieldObjects(arrayText string) []string {
var objects []string
depth := 0
start := -1
for i := 0; i < len(arrayText); i++ {
if arrayText[i] == '{' {
if depth == 0 {
start = i
}
depth++
} else if arrayText[i] == '}' {
depth--
if depth == 0 && start >= 0 {
objects = append(objects, arrayText[start:i+1])
start = -1
}
}
}
return objects
}
// parseFieldObject 解析包含编号、名称、类型和修饰符的单个字段对象。
func parseFieldObject(obj string) (*Field, error) {
// 提取字段编号。
noMatch := noRe.FindStringSubmatch(obj)
if noMatch == nil {
return nil, errors.New("missing field no")
}
no, _ := strconv.Atoi(noMatch[1])
// 提取字段名称。
nameMatch := nameRe.FindStringSubmatch(obj)
if nameMatch == nil {
return nil, errors.New("missing field name")
}
name := strings.TrimSpace(nameMatch[1])
if !fieldNameRe.MatchString(name) {
return nil, fmt.Errorf("invalid field name: %s", name)
}
// 提取字段类别。
kindMatch := kindRe.FindStringSubmatch(obj)
if kindMatch == nil {
return nil, errors.New("missing field kind")
}
kind := strings.TrimSpace(kindMatch[1])
field := &Field{
No: no,
Name: name,
Kind: kind,
}
// 类型 T 可以是标量编号、变量名或 getEnumType 枚举调用。
// 枚举优先匹配 getEnumType 调用。
if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil {
field.T = enumMatch[1]
} else {
// 其余类型匹配普通 T 属性值。
if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.T = t
} else {
field.T = tMatch[1]
}
} else if shorthandTRe.MatchString(obj) {
field.T = "T"
}
}
// 仅在当前字段对象内检查 oneof 分组。
if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil {
candidate := strings.TrimSpace(oneofMatch[1])
if oneofNameRe.MatchString(candidate) {
field.Oneof = candidate
}
}
// 仅在当前字段对象内检查 repeated;压缩 JS 中 !0 表示真。
if repeatedRe.MatchString(obj) {
field.Repeated = true
}
// 仅在当前字段对象内检查 optional。
if optRe.MatchString(obj) {
field.Opt = true
}
// map 字段通过 K 键类型和 V 值描述共同表示。
if field.Kind == "map" {
// 提取 map 键类型。
if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil {
field.MapKey, _ = strconv.Atoi(keyMatch[1])
}
// 提取 map 值类型,兼容属性顺序变化。
if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil {
valueObj := valueMatch[1]
if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil {
field.MapValueKind = kindMatch[1]
}
if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil {
if t, err := strconv.Atoi(tMatch[1]); err == nil {
field.MapValueT = t
} else {
field.MapValueT = tMatch[1]
}
}
}
}
return field, nil
}
+430
View File
@@ -0,0 +1,430 @@
// modules.go 扫描模块边界、合并声明并执行提取结果校验。
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jhump/protoreflect/desc"
"github.com/jhump/protoreflect/desc/protoparse"
)
// moduleStartRe 匹配 Webpack 数字模块的函数起点。
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
// buildModuleStarts 收集 bundle 内全部模块起始位置。
func buildModuleStarts(text string) []int {
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
starts := make([]int, 0, len(matches))
for _, match := range matches {
starts = append(starts, match[0])
}
return starts
}
// moduleStartForPos 查找指定源码位置所属的模块起点。
func moduleStartForPos(moduleStarts []int, pos int) int {
if len(moduleStarts) == 0 {
return 0
}
index := sort.Search(len(moduleStarts), func(i int) bool {
return moduleStarts[i] > pos
}) - 1
if index < 0 {
return 0
}
return moduleStarts[index]
}
// buildModuleImportIndex 建立模块局部变量到导入模块编号的映射。
func buildModuleImportIndex(text string, moduleStarts []int) map[int]map[string]int {
if len(moduleStarts) == 0 {
return nil
}
moduleMatches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
moduleStartByID := make(map[string]int, len(moduleMatches))
for _, match := range moduleMatches {
moduleStartByID[text[match[2]:match[3]]] = match[0]
}
importsByModule := make(map[int]map[string]int)
for index, moduleStart := range moduleStarts {
moduleEnd := len(text)
if index+1 < len(moduleStarts) {
moduleEnd = moduleStarts[index+1]
}
body := text[moduleStart:moduleEnd]
for _, match := range moduleImportRe.FindAllStringSubmatch(body, -1) {
targetModuleStart, ok := moduleStartByID[match[2]]
if !ok {
continue
}
if importsByModule[moduleStart] == nil {
importsByModule[moduleStart] = make(map[string]int)
}
importsByModule[moduleStart][match[1]] = targetModuleStart
}
}
return importsByModule
}
// ExtractProtosFromFiles 分别提取各 bundle,规范化类型引用后按全限定名合并。
// 多个 bundle 出现同名声明时优先保留靠前输入。
func ExtractProtosFromFiles(inputFiles []string, outputDir string) {
activeDiagnostics = newExtractionDiagnostics()
defer func() {
activeDiagnostics = nil
}()
var allMessages []Message
var allEnums []Enum
var allServices []Service
for _, inputFile := range inputFiles {
content, err := os.ReadFile(inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file %s: %v\n", inputFile, err)
os.Exit(1)
}
text := string(content)
moduleStarts := buildModuleStarts(text)
aliases := buildAliasIndex(text, moduleStarts)
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
messages := extractMessages(text, moduleStarts)
enums := extractEnums(text, moduleStarts)
services := extractServices(text, moduleStarts)
declared, extracted, missing := declarationCoverage(text, messages, enums, services)
activeDiagnostics.declaredTypes += declared
activeDiagnostics.extractedTypes += extracted
activeDiagnostics.missingDeclarations = append(activeDiagnostics.missingDeclarations, missing...)
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
normalizeTypeReferences(messages, services, resolver)
allMessages = append(allMessages, messages...)
allEnums = append(allEnums, enums...)
allServices = append(allServices, services...)
}
messages := mergeMessagesByTypeName(allMessages)
enums := mergeEnumsByTypeName(allEnums)
services := mergeServicesByTypeName(allServices)
for _, msg := range messages {
if len(msg.Fields) == 0 {
activeDiagnostics.emptyMessages = append(activeDiagnostics.emptyMessages, msg.TypeName)
}
}
sort.Strings(activeDiagnostics.missingDeclarations)
activeDiagnostics.missingDeclarations = compactStrings(activeDiagnostics.missingDeclarations)
resolver := newTypeResolver(messages, enums, nil, nil)
generateProtos(messages, enums, services, resolver, outputDir)
validateErr := validateGeneratedProtos(outputDir, activeDiagnostics)
printDiagnosticsSummary(activeDiagnostics)
if strictExtractionValidation && hasValidationFailure(activeDiagnostics, validateErr) {
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation failed: %v\n", validateErr)
}
os.Exit(1)
}
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Validation warning: %v\n", validateErr)
}
fmt.Printf("提取完成: %d 个消息, %d 个枚举, %d 个服务\n", len(messages), len(enums), len(services))
}
// normalizeTypeReferences 把字段和方法引用统一转换为全限定类型名。
func normalizeTypeReferences(messages []Message, services []Service, resolver *TypeResolver) {
resolve := func(ref any, contextPos int, moduleStart int, pkg string, kind string) any {
symbol, ok := ref.(string)
if !ok || strings.TrimSpace(symbol) == "" {
return ref
}
if typeName, resolved := resolver.ResolveTypeName(symbol, contextPos, moduleStart, pkg, kind); resolved {
return typeName
}
return ref
}
for messageIndex := range messages {
message := &messages[messageIndex]
for fieldIndex := range message.Fields {
field := &message.Fields[fieldIndex]
if field.Kind == "message" || field.Kind == "enum" {
field.T = resolve(field.T, message.Pos, message.ModuleStart, message.Package, field.Kind)
}
if field.Kind == "map" && (field.MapValueKind == "message" || field.MapValueKind == "enum") {
field.MapValueT = resolve(field.MapValueT, message.Pos, message.ModuleStart, message.Package, field.MapValueKind)
}
}
}
for serviceIndex := range services {
service := &services[serviceIndex]
for methodIndex := range service.Methods {
method := &service.Methods[methodIndex]
if typeName, ok := resolve(method.InputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.InputType = typeName
}
if typeName, ok := resolve(method.OutputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
method.OutputType = typeName
}
}
}
}
// mergeMessagesByTypeName 按全限定名合并消息并保留首次声明。
func mergeMessagesByTypeName(messages []Message) []Message {
seen := make(map[string]bool)
merged := make([]Message, 0, len(messages))
for _, message := range messages {
if seen[message.TypeName] {
continue
}
seen[message.TypeName] = true
merged = append(merged, message)
}
return merged
}
// mergeEnumsByTypeName 按全限定名合并枚举并保留首次声明。
func mergeEnumsByTypeName(enums []Enum) []Enum {
seen := make(map[string]bool)
merged := make([]Enum, 0, len(enums))
for _, enum := range enums {
if seen[enum.TypeName] {
continue
}
seen[enum.TypeName] = true
merged = append(merged, enum)
}
return merged
}
// mergeServicesByTypeName 按全限定名合并服务并保留首次声明。
func mergeServicesByTypeName(services []Service) []Service {
seen := make(map[string]bool)
merged := make([]Service, 0, len(services))
for _, service := range services {
if seen[service.TypeName] {
continue
}
seen[service.TypeName] = true
merged = append(merged, service)
}
return merged
}
// compactStrings 清理、去重并排序诊断字符串。
func compactStrings(values []string) []string {
if len(values) == 0 {
return nil
}
compacted := values[:1]
for _, value := range values[1:] {
if value != compacted[len(compacted)-1] {
compacted = append(compacted, value)
}
}
return compacted
}
// hasValidationFailure 判断诊断结果是否达到失败条件。
func hasValidationFailure(diag *extractionDiagnostics, validateErr error) bool {
if validateErr != nil {
return true
}
if diag == nil {
return false
}
if diag.skippedFieldObjects > 0 {
return true
}
if len(diag.unresolvedTypeRefs) > 0 {
return true
}
if len(diag.placeholderHits) > 0 {
return true
}
if len(diag.missingDeclarations) > 0 {
return true
}
return false
}
// printDiagnosticsSummary 输出提取覆盖率和异常样本摘要。
func printDiagnosticsSummary(diag *extractionDiagnostics) {
if diag == nil {
return
}
fmt.Printf(
"诊断汇总: fields %d/%d 解析成功, declarations %d/%d 已提取, skipped=%d, unresolved=%d, placeholders=%d, empty_messages=%d\n",
diag.parsedFieldObjects,
diag.totalFieldObjects,
diag.extractedTypes,
diag.declaredTypes,
diag.skippedFieldObjects,
len(diag.unresolvedTypeRefs),
len(diag.placeholderHits),
len(diag.emptyMessages),
)
if diag.skippedFieldObjects > 0 && len(diag.skippedFieldSamples) > 0 {
fmt.Println("字段解析失败样例:")
for _, sample := range diag.skippedFieldSamples {
fmt.Printf(" - %s\n", sample)
}
}
if len(diag.unresolvedTypeRefs) > 0 {
keys := make([]string, 0, len(diag.unresolvedTypeRefs))
for key := range diag.unresolvedTypeRefs {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Println("未解析类型引用:")
for _, key := range keys {
fmt.Printf(" - %s (%d)\n", key, diag.unresolvedTypeRefs[key])
}
}
if len(diag.placeholderHits) > 0 {
fmt.Println("占位字段命中:")
for i, hit := range diag.placeholderHits {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.placeholderHits)-20)
break
}
fmt.Printf(" - %s\n", hit)
}
}
if len(diag.missingDeclarations) > 0 {
fmt.Println("未提取的 Proto 声明:")
for i, typeName := range diag.missingDeclarations {
if i >= 20 {
fmt.Printf(" - ... and %d more\n", len(diag.missingDeclarations)-20)
break
}
fmt.Printf(" - %s\n", typeName)
}
}
}
// declarationCoverage 比较 bundle 声明数量与实际提取数量。
func declarationCoverage(text string, messages []Message, enums []Enum, services []Service) (int, int, []string) {
declared := make(map[string]bool)
collect := func(re *regexp.Regexp) {
for _, match := range re.FindAllStringSubmatch(text, -1) {
typeName := strings.TrimSpace(match[1])
pkg, _ := parseTypeName(typeName)
if typeName != "" && !isGooglePkg(pkg) {
declared[typeName] = true
}
}
}
collect(typeNameDeclarationRe)
collect(serviceDeclarationRe)
collect(messageDeclarationRe)
collect(enumDeclarationRe)
collect(legacyEnumDeclarationRe)
extracted := make(map[string]bool)
for _, message := range messages {
extracted[message.TypeName] = true
}
for _, enum := range enums {
extracted[enum.TypeName] = true
}
for _, service := range services {
extracted[service.TypeName] = true
}
matched := 0
missing := make([]string, 0)
for typeName := range declared {
if extracted[typeName] {
matched++
continue
}
missing = append(missing, typeName)
}
sort.Strings(missing)
return len(declared), matched, missing
}
// validateGeneratedProtos 检查生成文件语法占位和关键 Agent 结构。
func validateGeneratedProtos(outputDir string, diag *extractionDiagnostics) error {
entries, err := os.ReadDir(outputDir)
if err != nil {
return fmt.Errorf("read output dir failed: %w", err)
}
protoFiles := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".proto") {
protoFiles = append(protoFiles, name)
}
}
if len(protoFiles) == 0 {
return errors.New("no generated proto files found")
}
sort.Strings(protoFiles)
for _, file := range protoFiles {
body, readErr := os.ReadFile(filepath.Join(outputDir, file))
if readErr != nil {
return fmt.Errorf("read generated proto failed: %s: %w", file, readErr)
}
lines := strings.Split(string(body), "\n")
for idx, line := range lines {
if placeholderRe.MatchString(line) && diag != nil {
hit := fmt.Sprintf("%s:%d: %s", file, idx+1, strings.TrimSpace(line))
diag.placeholderHits = append(diag.placeholderHits, hit)
}
}
if err := validateRequiredAgentShapes(file, string(body)); err != nil {
return err
}
}
parser := protoparse.Parser{
ImportPaths: []string{outputDir},
LookupImport: desc.LoadFileDescriptor,
}
if _, parseErr := parser.ParseFiles(protoFiles...); parseErr != nil {
return fmt.Errorf("parse generated proto failed: %w", parseErr)
}
return nil
}
// validateRequiredAgentShapes 校验 Agent 流控消息的必要字段形状。
func validateRequiredAgentShapes(file string, body string) error {
if strings.Contains(body, "message ExecClientControlMessage") && !streamCloseRe.MatchString(body) {
return fmt.Errorf("%s: ExecClientControlMessage.stream_close must be ExecClientStreamClose", file)
}
if strings.Contains(body, "message ShellStream") && !shellStdoutRe.MatchString(body) {
return fmt.Errorf("%s: ShellStream.stdout must be ShellStreamStdout", file)
}
return nil
}
+395
View File
@@ -0,0 +1,395 @@
// renderer.go 把协议声明树渲染为稳定的 proto 文本。
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// generateProtoFile 把单个协议包的声明渲染并写入文件。
func generateProtoFile(pkgName string, messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
// 先收集全部跨包标准依赖。
imports := collectImports(pkgName, messages, services, resolver)
var sb strings.Builder
sb.WriteString(`syntax = "proto3";` + "\n\n")
sb.WriteString(fmt.Sprintf("package %s;\n\n", pkgName))
// 按稳定顺序写入 import。
if len(imports) > 0 {
sortedImports := make([]string, 0, len(imports))
for imp := range imports {
sortedImports = append(sortedImports, imp)
}
sort.Strings(sortedImports)
for _, imp := range sortedImports {
sb.WriteString(fmt.Sprintf("import \"%s\";\n", imp))
}
sb.WriteString("\n")
}
goPackagePath := strings.ReplaceAll(pkgName, ".", "/")
goPackageName := strings.ReplaceAll(pkgName, ".", "")
sb.WriteString(fmt.Sprintf(`option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/%s;%s";`+"\n\n", goPackagePath, goPackageName))
// 建立嵌套类型树。
root := &TypeNode{Children: make(map[string]*TypeNode)}
for i := range messages {
msg := &messages[i]
path := getNestedPath(msg.ShortName)
insertMessage(root, path, msg)
}
for i := range enums {
enum := &enums[i]
path := getNestedPath(enum.ShortName)
insertEnum(root, path, enum)
}
// 写入全部顶层类型。
writeTypeTree(root, &sb, resolver, 0, pkgName)
// 写入服务声明。
sort.Slice(services, func(i, j int) bool {
return services[i].ShortName < services[j].ShortName
})
for _, svc := range services {
// 写入服务来源注释。
sb.WriteString(fmt.Sprintf("// Source: %s (var: %s)\n", svc.TypeName, svc.VarName))
sb.WriteString(fmt.Sprintf("service %s {\n", svc.ShortName))
for _, m := range svc.Methods {
inputType := resolveMethodType(m.InputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
outputType := resolveMethodType(m.OutputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
switch m.Kind {
case "ServerStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (stream %s) {}\n", m.Name, inputType, outputType))
case "ClientStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (%s) {}\n", m.Name, inputType, outputType))
case "BiDiStreaming":
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (stream %s) {}\n", m.Name, inputType, outputType))
default: // 默认为一元调用。
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (%s) {}\n", m.Name, inputType, outputType))
}
}
sb.WriteString("}\n\n")
}
// 每个协议包写入扁平输出目录中的单个文件。
fileName := strings.ReplaceAll(pkgName, ".", "_") + ".proto"
filePath := filepath.Join(outputDir, fileName)
os.WriteFile(filePath, []byte(sb.String()), 0644)
fmt.Printf("Generated: %s (%d messages, %d enums, %d services)\n", filePath, len(messages), len(enums), len(services))
}
// resolveMethodType 解析方法消息类型并处理本地复制类型。
func resolveMethodType(ref string, resolver *TypeResolver, currentPkg string, contextPos int, contextModuleStart int) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
activeDiagnostics.addUnresolvedType("method:" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 检查类型是否由其他包复制到当前包。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
return shortName
}
}
return refPkg + "." + shortName
}
// insertMessage 把消息插入嵌套类型树。
func insertMessage(node *TypeNode, path []string, msg *Message) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Message = msg
} else {
insertMessage(child, path[1:], msg)
}
}
// insertEnum 把枚举插入嵌套类型树。
func insertEnum(node *TypeNode, path []string, enum *Enum) {
if len(path) == 0 {
return
}
name := path[0]
if node.Children == nil {
node.Children = make(map[string]*TypeNode)
}
child, exists := node.Children[name]
if !exists {
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
node.Children[name] = child
}
if len(path) == 1 {
child.Enum = enum
} else {
insertEnum(child, path[1:], enum)
}
}
// writeTypeTree 按名称稳定输出嵌套消息和枚举。
func writeTypeTree(node *TypeNode, sb *strings.Builder, resolver *TypeResolver, indent int, currentPkg string) {
// 对子节点排序以保证输出稳定。
var names []string
for name := range node.Children {
names = append(names, name)
}
sort.Strings(names)
indentStr := strings.Repeat(" ", indent)
for _, name := range names {
child := node.Children[name]
if child.Enum != nil {
// 检查枚举是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Enum.ShortName]; ok {
originalType = orig
}
}
// 写入枚举来源注释。
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, child.Enum.VarName))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Enum.TypeName, child.Enum.VarName))
}
// 写入枚举声明。
sb.WriteString(fmt.Sprintf("%senum %s {\n", indentStr, name))
for _, v := range child.Enum.Values {
sb.WriteString(fmt.Sprintf("%s %s = %d;\n", indentStr, v.Name, v.No))
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
} else if child.Message != nil || len(child.Children) > 0 {
// 写入消息来源注释。
if child.Message != nil {
varInfo := child.Message.VarName
if child.Message.InternalName != "" && child.Message.InternalName != child.Message.VarName {
varInfo = fmt.Sprintf("%s, class: %s", child.Message.VarName, child.Message.InternalName)
}
// 检查消息是否来自其他包。
originalType := ""
if copied := copiedTypes[currentPkg]; copied != nil {
if orig, ok := copied[child.Message.ShortName]; ok {
originalType = orig
}
}
if originalType != "" {
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, varInfo))
} else {
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Message.TypeName, varInfo))
}
}
// 即使节点只承载嵌套类型,也要写入消息容器。
sb.WriteString(fmt.Sprintf("%smessage %s {\n", indentStr, name))
// 先写入嵌套类型。
writeTypeTree(child, sb, resolver, indent+1, currentPkg)
// 当前节点有消息声明时再写字段。
if child.Message != nil {
writeMessageFields(child.Message, sb, resolver, indent+1)
}
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
}
}
}
// writeMessageFields 输出普通字段和 oneof 分组。
func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolver, indent int) {
indentStr := strings.Repeat(" ", indent)
// 获取当前消息路径,用于解析相对嵌套类型。
msgPath := msg.ShortName
currentPkg := msg.Package
preferredPkg, _ := parseTypeName(msg.TypeName)
// 按 oneof 分组字段。
oneofGroups := make(map[string][]Field)
var regularFields []Field
for _, f := range msg.Fields {
if f.Oneof != "" {
oneofGroups[f.Oneof] = append(oneofGroups[f.Oneof], f)
} else {
regularFields = append(regularFields, f)
}
}
// 先写普通字段。
for _, f := range regularFields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
prefix := ""
if f.Repeated {
prefix = "repeated "
} else if f.Opt {
prefix = "optional "
}
sb.WriteString(fmt.Sprintf("%s%s%s %s = %d;\n", indentStr, prefix, fieldType, f.Name, f.No))
}
// 再写 oneof 字段组。
var oneofNames []string
for name := range oneofGroups {
oneofNames = append(oneofNames, name)
}
sort.Strings(oneofNames)
for _, oneofName := range oneofNames {
fields := oneofGroups[oneofName]
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
for _, f := range fields {
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
}
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
}
}
// parseTypeName 从全限定类型名拆出协议包和完整嵌套路径。
func parseTypeName(typeName string) (pkg, shortName string) {
// 优先匹配 xxx.vN.Rest 形式的版本化协议包。
versionRe := regexp.MustCompile(`^([\w.]+\.v\d+)\.(.+)$`)
if match := versionRe.FindStringSubmatch(typeName); match != nil {
return match[1], match[2]
}
// 单独处理 google.protobuf 标准类型。
if strings.HasPrefix(typeName, "google.protobuf.") {
rest := strings.TrimPrefix(typeName, "google.protobuf.")
return "google.protobuf", rest
}
// 单独处理 google.rpc 标准类型。
if strings.HasPrefix(typeName, "google.rpc.") {
rest := strings.TrimPrefix(typeName, "google.rpc.")
return "google.rpc", rest
}
// 无法识别包版本时按最后一个点回退拆分。
parts := strings.Split(typeName, ".")
if len(parts) > 1 {
return strings.Join(parts[:len(parts)-1], "."), parts[len(parts)-1]
}
return "", typeName
}
// getNestedPath 把嵌套类型名拆成逐级路径。
func getNestedPath(shortName string) []string {
return strings.Split(shortName, ".")
}
// resolveFieldTypeWithPkg 结合当前包和父消息路径解析字段类型。
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int) string {
resolveNamedType := func(ref string, expectedKind string) string {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
if !ok {
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
return fallbackTypeToken(ref)
}
refPkg, shortName := parseTypeName(typeName)
// 类型位于同一父消息下时使用相对路径。
if parentPath != "" && strings.HasPrefix(shortName, parentPath+".") {
// 例如消息内部将 ConversationMessage.CodeChunk 缩短为 CodeChunk。
return strings.TrimPrefix(shortName, parentPath+".")
}
// 同包类型只使用短名称。
if refPkg == currentPkg || refPkg == "" {
return shortName
}
// 循环依赖中优先使用已经复制到当前包的类型。
if copied := copiedTypes[currentPkg]; copied != nil {
if _, isCopied := copied[shortName]; isCopied {
// 本地存在复制类型时使用短名称。
return shortName
}
}
// 其余跨包引用保留全限定类型名。
return refPkg + "." + shortName
}
if f.Kind == "scalar" {
if t, ok := f.T.(int); ok {
return scalarTypes[t]
}
if t, ok := f.T.(float64); ok {
return scalarTypes[int(t)]
}
}
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
return resolveNamedType(ref, f.Kind)
}
}
if f.Kind == "map" {
// map 字段分别解析键和值类型。
keyType := scalarTypes[f.MapKey]
if keyType == "" {
keyType = "string" // 未知标量默认使用字符串。
}
var valueType string
if f.MapValueKind == "scalar" {
if t, ok := f.MapValueT.(int); ok {
valueType = scalarTypes[t]
} else if t, ok := f.MapValueT.(float64); ok {
valueType = scalarTypes[int(t)]
}
} else if f.MapValueKind == "message" || f.MapValueKind == "enum" {
if ref, ok := f.MapValueT.(string); ok {
valueType = resolveNamedType(ref, f.MapValueKind)
}
}
if valueType == "" {
valueType = "bytes"
}
return fmt.Sprintf("map<%s, %s>", keyType, valueType)
}
return "bytes" // 未识别字段类型时回退为字节串。
}
+423
View File
@@ -0,0 +1,423 @@
// resolver.go 解析压缩 bundle 中的局部符号、模块别名和导出别名。
package main
import (
"regexp"
"sort"
"strings"
)
// newTypeResolver 建立消息、枚举和模块别名的统一索引。
func newTypeResolver(messages []Message, enums []Enum, aliases aliasIndex, exportAliases aliasIndex) *TypeResolver {
resolver := &TypeResolver{
bySymbol: make(map[string][]symbolDef),
byAlias: make(map[string][]symbolDef),
byShort: make(map[string][]symbolDef),
}
add := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
def := symbolDef{TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind}
resolver.bySymbol[symbol] = append(resolver.bySymbol[symbol], def)
_, shortName := parseTypeName(typeName)
if shortName != "" {
resolver.byShort[shortName] = append(resolver.byShort[shortName], def)
underscoreAlias := strings.ReplaceAll(shortName, ".", "_")
if underscoreAlias != shortName {
resolver.byShort[underscoreAlias] = append(resolver.byShort[underscoreAlias], def)
}
if idx := strings.LastIndex(shortName, "."); idx > 0 && idx+1 < len(shortName) {
resolver.byShort[shortName[idx+1:]] = append(resolver.byShort[shortName[idx+1:]], def)
}
if idx := strings.LastIndex(underscoreAlias, "_"); idx > 0 && idx+1 < len(underscoreAlias) {
resolver.byShort[underscoreAlias[idx+1:]] = append(resolver.byShort[underscoreAlias[idx+1:]], def)
}
}
}
addAlias := func(symbol, typeName string, pos int, moduleStart int, kind string) {
symbol = strings.TrimSpace(symbol)
typeName = strings.TrimSpace(typeName)
if symbol == "" || typeName == "" {
return
}
resolver.byAlias[symbol] = append(resolver.byAlias[symbol], symbolDef{
TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind,
})
}
for _, msg := range messages {
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
if msg.InternalName != "" && msg.InternalName != msg.VarName {
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
for _, alias := range aliasesForSymbols(aliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
for _, alias := range aliasesForSymbols(aliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
for _, msg := range messages {
for _, alias := range aliasesForSymbols(exportAliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
}
}
for _, enum := range enums {
for _, alias := range aliasesForSymbols(exportAliases[enum.ModuleStart], enum.VarName) {
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
}
}
return resolver
}
// buildAliasIndex 提取变量声明和赋值形成的局部别名。
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
directByModule := make(map[int]map[string]string)
addMatches := func(matches [][]int) {
for _, match := range matches {
alias := strings.TrimSpace(text[match[2]:match[3]])
target := strings.TrimSpace(text[match[4]:match[5]])
if alias == "" || target == "" || alias == target {
continue
}
moduleStart := moduleStartForPos(moduleStarts, match[0])
if directByModule[moduleStart] == nil {
directByModule[moduleStart] = make(map[string]string)
}
directByModule[moduleStart][alias] = target
}
}
addMatches(varAliasRe.FindAllStringSubmatchIndex(text, -1))
addMatches(assignmentAliasRe.FindAllStringSubmatchIndex(text, -1))
resolveRoot := func(direct map[string]string, symbol string) string {
seen := make(map[string]bool)
current := symbol
for {
if seen[current] {
return symbol
}
seen[current] = true
next := direct[current]
if next == "" {
return current
}
current = next
}
}
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
for moduleStart, direct := range directByModule {
for alias := range direct {
root := resolveRoot(direct, alias)
addAlias(moduleStart, root, alias)
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// buildWebpackExportAliasIndex 提取 Webpack 导出表中的符号别名。
func buildWebpackExportAliasIndex(text string, moduleStarts []int) aliasIndex {
aliasSets := make(map[int]map[string]map[string]bool)
addAlias := func(moduleStart int, root string, alias string) {
root = strings.TrimSpace(root)
alias = strings.TrimSpace(alias)
if root == "" || alias == "" || root == alias {
return
}
if aliasSets[moduleStart] == nil {
aliasSets[moduleStart] = make(map[string]map[string]bool)
}
if aliasSets[moduleStart][root] == nil {
aliasSets[moduleStart][root] = make(map[string]bool)
}
aliasSets[moduleStart][root][alias] = true
}
// Webpack 通过 n.d(t, { KS: () => T }) 暴露成员;服务使用 r.KS,消息定义使用局部符号 T。
for _, blockMatch := range webpackExportBlockRe.FindAllStringIndex(text, -1) {
moduleStart := moduleStartForPos(moduleStarts, blockMatch[0])
blockStart := blockMatch[1] - 1
blockEnd := findMatchingBrace(text, blockStart)
if blockEnd == -1 {
continue
}
block := text[blockStart:blockEnd]
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
addAlias(moduleStart, entry[2], entry[1])
}
}
if len(aliasSets) == 0 {
return nil
}
aliases := make(aliasIndex, len(aliasSets))
for moduleStart, roots := range aliasSets {
aliases[moduleStart] = make(map[string][]string, len(roots))
for root, set := range roots {
for alias := range set {
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
}
sort.Strings(aliases[moduleStart][root])
}
}
return aliases
}
// aliasesForSymbols 返回目标符号集合对应的去重别名。
func aliasesForSymbols(aliases map[string][]string, symbols ...string) []string {
if len(aliases) == 0 {
return nil
}
seen := make(map[string]bool)
var result []string
for _, symbol := range symbols {
for _, alias := range aliases[strings.TrimSpace(symbol)] {
if alias == "" || seen[alias] {
continue
}
seen[alias] = true
result = append(result, alias)
}
}
sort.Strings(result)
return result
}
// looksLikeFullTypeName 判断引用是否已经是全限定协议类型名。
func looksLikeFullTypeName(ref string) bool {
trimmed := strings.TrimSpace(ref)
if strings.HasPrefix(trimmed, "google.protobuf.") || strings.HasPrefix(trimmed, "google.rpc.") {
return true
}
matched, _ := regexp.MatchString(`^[\w.]+\.v\d+\.[\w.]+$`, trimmed)
return matched
}
// pickBestDefinition 按模块、类别、首选包和源码距离选择定义。
func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (symbolDef, bool) {
if len(candidates) == 0 {
return symbolDef{}, false
}
filtered := candidates
if strings.TrimSpace(expectedKind) != "" {
tmp := make([]symbolDef, 0, len(candidates))
for _, item := range candidates {
if item.Kind == expectedKind {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if strings.TrimSpace(preferredPkg) != "" {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
pkg, _ := parseTypeName(item.TypeName)
if pkg == preferredPkg {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
if contextModuleStart > 0 {
tmp := make([]symbolDef, 0, len(filtered))
for _, item := range filtered {
if item.ModuleStart == contextModuleStart {
tmp = append(tmp, item)
}
}
if len(tmp) > 0 {
filtered = tmp
}
}
// 选择绝对距离最近的定义,距离相同时优先前向定义。
bestIndex := -1
bestDistance := 0
bestIsFuture := false
for index, item := range filtered {
distance := absInt(item.Pos - contextPos)
isFuture := item.Pos > contextPos
if bestIndex == -1 {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance < bestDistance {
bestIndex = index
bestDistance = distance
bestIsFuture = isFuture
continue
}
if distance == bestDistance {
// 距离相同时优先当前位置之前的定义。
if bestIsFuture && !isFuture {
bestIndex = index
bestIsFuture = isFuture
}
}
}
if bestIndex < 0 {
return symbolDef{}, false
}
return filtered[bestIndex], true
}
// ResolveTypeName 把局部变量、别名或短名称解析为全限定类型名。
func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (string, bool) {
if resolver == nil {
return "", false
}
trimmed := strings.TrimSpace(ref)
if trimmed == "" {
return "", false
}
if looksLikeFullTypeName(trimmed) {
return trimmed, true
}
resolveBySymbol := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.bySymbol[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByAlias := func(symbol string, targetModuleStart int) (string, bool) {
candidates := resolver.byAlias[symbol]
if len(candidates) == 0 {
return "", false
}
best, ok := pickBestDefinition(candidates, contextPos, targetModuleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
candidates := resolver.byShort[symbol]
if len(candidates) == 0 {
return "", false
}
moduleStart := 0
if preferSameModule {
moduleStart = contextModuleStart
}
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
if !ok {
return "", false
}
return best.TypeName, true
}
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if typeName, ok := resolveByAlias(trimmed, 0); ok {
return typeName, true
}
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
return typeName, true
}
if strings.Contains(trimmed, ".") {
parts := strings.Split(trimmed, ".")
first := parts[0]
last := parts[len(parts)-1]
targetModuleStart := 0
if imports := resolver.moduleImports[contextModuleStart]; imports != nil {
targetModuleStart = imports[first]
}
if typeName, ok := resolveByAlias(last, targetModuleStart); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(last, false); ok {
return typeName, true
}
if typeName, ok := resolveByShort(last, false); ok {
return typeName, true
}
if typeName, ok := resolveBySymbol(first, false); ok {
return typeName, true
}
}
return "", false
}
// fallbackTypeToken 从无法解析的引用生成合法类型占位名。
func fallbackTypeToken(ref string) string {
token := strings.TrimSpace(ref)
if token == "" {
return token
}
if strings.Contains(token, ".") {
parts := strings.Split(token, ".")
return parts[len(parts)-1]
}
return token
}
// absInt 返回整数绝对值。
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
+190
View File
@@ -0,0 +1,190 @@
// services.go 解析枚举、服务方法和压缩对象的配对括号。
package main
import (
"regexp"
"strconv"
)
// extractEnums 从旧式和工厂式声明中提取枚举。
func extractEnums(text string, moduleStarts []int) []Enum {
var enums []Enum
enumExists := func(typeName, varName string) bool {
for _, existing := range enums {
if existing.TypeName == typeName && existing.VarName == varName {
return true
}
}
return false
}
// 匹配任意包中的 setEnumType(XXX, "xxx.v1.EnumName", [...]) 枚举声明。
// JS 变量名可以包含 $ 符号
enumRe := regexp.MustCompile(`setEnumType\s*\(\s*([\w$]+)\s*,\s*"([\w.]+)"\s*,\s*\[`)
matches := enumRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
// 提取枚举值数组。
bracketStart := match[1] - 1
values := extractEnumValues(text, bracketStart)
pkg, shortName := parseTypeName(typeName)
enum := Enum{
TypeName: typeName,
VarName: varName,
Values: values,
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
}
enums = append(enums, enum)
}
// 匹配现代 @bufbuild/protobuf 工厂形式,例如 Role=A.makeEnum("aiserver.v1.InferenceMessageRole",[{...}])。
enumFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeEnum\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
factoryMatches := enumFactoryRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range factoryMatches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
if enumExists(typeName, varName) {
continue
}
bracketStart := match[1] - 1
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
continue
}
pkg, shortName := parseTypeName(typeName)
enums = append(enums, Enum{
TypeName: typeName,
VarName: varName,
Values: extractEnumValues(text, bracketStart),
Package: pkg,
ShortName: shortName,
Pos: match[0],
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
})
}
return enums
}
// extractServices 从命名或匿名描述符中提取服务。
func extractServices(text string, moduleStarts []int) []Service {
var services []Service
seenTypeNames := make(map[string]bool)
appendService := func(varName, typeName string, pos, methodsStart int) {
if seenTypeNames[typeName] {
return
}
methodsEnd := findMatchingBrace(text, methodsStart)
if methodsEnd == -1 {
return
}
pkg, shortName := parseTypeName(typeName)
services = append(services, Service{
TypeName: typeName,
VarName: varName,
Methods: extractMethods(text[methodsStart:methodsEnd]),
Package: pkg,
ShortName: shortName,
Pos: pos,
ModuleStart: moduleStartForPos(moduleStarts, pos),
})
seenTypeNames[typeName] = true
}
// 匹配 VarName = { typeName: "xxx.v1.ServiceName", methods: { ... } } 服务对象。
serviceRe := regexp.MustCompile(`([\w$]+)\s*=\s*\{\s*typeName:\s*"([\w.]+)"\s*,\s*methods:\s*\{`)
matches := serviceRe.FindAllStringSubmatchIndex(text, -1)
for _, match := range matches {
varName := text[match[2]:match[3]]
typeName := text[match[4]:match[5]]
appendService(varName, typeName, match[0], match[1]-1)
}
// 部分 bundle 把服务描述符直接放入数组,不预先赋给变量。
anonymousServiceRe := regexp.MustCompile(`\{\s*typeName:\s*["']([\w.]+)["']\s*,\s*methods:\s*\{`)
for _, match := range anonymousServiceRe.FindAllStringSubmatchIndex(text, -1) {
typeName := text[match[2]:match[3]]
appendService("", typeName, match[0], match[1]-1)
}
return services
}
// extractMethods 解析服务对象中的 RPC 方法列表。
func extractMethods(methodsText string) []Method {
var methods []Method
// 匹配包含方法名、输入、输出和调用类型的方法对象。
methodRe := regexp.MustCompile(`\w+:\s*\{\s*name:\s*"([^"]+)"\s*,\s*I:\s*([\w$.]+)\s*,\s*O:\s*([\w$.]+)\s*,\s*kind:\s*[\w$.]+\.(Unary|ServerStreaming|ClientStreaming|BiDiStreaming)`)
matches := methodRe.FindAllStringSubmatch(methodsText, -1)
for _, m := range matches {
method := Method{
Name: m[1],
InputType: m[2],
OutputType: m[3],
Kind: m[4],
}
methods = append(methods, method)
}
return methods
}
// findMatchingBrace 查找花括号块的结束位置。
func findMatchingBrace(text string, start int) int {
depth := 0
for i := start; i < len(text); i++ {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1
}
}
}
return -1
}
// extractEnumValues 从数组起点解析枚举值。
func extractEnumValues(text string, start int) []EnumValue {
// 查找数组的配对结束括号。
depth := 0
end := start
for i := start; i < len(text); i++ {
if text[i] == '[' {
depth++
} else if text[i] == ']' {
depth--
if depth == 0 {
end = i + 1
break
}
}
}
arrayText := text[start:end]
var values []EnumValue
valueRe := regexp.MustCompile(`\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"`)
matches := valueRe.FindAllStringSubmatch(arrayText, -1)
for _, m := range matches {
no, _ := strconv.Atoi(m[1])
values = append(values, EnumValue{No: no, Name: m[2]})
}
return values
}
+260
View File
@@ -0,0 +1,260 @@
// types.go 定义协议提取器的领域结构、诊断状态和基础类型映射。
package main
import (
"fmt"
"regexp"
"strings"
)
// isGooglePkg 判断是否为无需重复生成的 Google 标准包。
func isGooglePkg(pkg string) bool {
return pkg == "google.protobuf" || pkg == "google.rpc"
}
// scalarTypes 把运行时标量编号映射为 proto 类型。
var scalarTypes = map[int]string{
1: "double",
2: "float",
3: "int64",
4: "uint64",
5: "int32",
6: "fixed64",
7: "fixed32",
8: "bool",
9: "string",
12: "bytes",
13: "uint32",
15: "sfixed32",
16: "sfixed64",
17: "sint32",
18: "sint64",
}
// strictExtractionValidation 控制校验失败是否终止提取。
var strictExtractionValidation = true
// extractionDiagnostics 汇总字段解析和类型解析诊断。
type extractionDiagnostics struct {
totalFieldObjects int
parsedFieldObjects int
skippedFieldObjects int
skippedFieldSamples []string
unresolvedTypeRefs map[string]int
emptyMessages []string
placeholderHits []string
declaredTypes int
extractedTypes int
missingDeclarations []string
}
// newExtractionDiagnostics 创建一次提取任务的诊断容器。
func newExtractionDiagnostics() *extractionDiagnostics {
return &extractionDiagnostics{
unresolvedTypeRefs: make(map[string]int),
}
}
// addSkippedField 记录未能解析的字段样本和原因。
func (d *extractionDiagnostics) addSkippedField(fieldObject string, reason error) {
if d == nil {
return
}
d.totalFieldObjects++
d.skippedFieldObjects++
if len(d.skippedFieldSamples) < 20 {
trimmed := strings.TrimSpace(fieldObject)
if len(trimmed) > 140 {
trimmed = trimmed[:140] + "..."
}
if reason != nil {
d.skippedFieldSamples = append(d.skippedFieldSamples, fmt.Sprintf("%s | %s", reason.Error(), trimmed))
} else {
d.skippedFieldSamples = append(d.skippedFieldSamples, trimmed)
}
}
}
// addParsedField 累计成功解析的字段数量。
func (d *extractionDiagnostics) addParsedField() {
if d == nil {
return
}
d.totalFieldObjects++
d.parsedFieldObjects++
}
// addUnresolvedType 按引用名称累计类型解析失败次数。
func (d *extractionDiagnostics) addUnresolvedType(ref string) {
if d == nil {
return
}
key := strings.TrimSpace(ref)
if key == "" {
key = "<empty>"
}
d.unresolvedTypeRefs[key]++
}
// SetStrictMode 设置校验失败是否终止提取。
func SetStrictMode(enabled bool) {
strictExtractionValidation = enabled
}
// activeDiagnostics 指向当前提取任务的诊断状态。
var activeDiagnostics *extractionDiagnostics
// 字段解析正则覆盖压缩 bundle 的各类声明形式。
var (
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
assignmentAliasRe = regexp.MustCompile(`(?:^|[;,({])\s*([\w$]+)\s*=\s*([\w$]+)\s*([,;}])`)
webpackExportBlockRe = regexp.MustCompile(`[\w$]+\.d\(\s*[\w$]+\s*,\s*\{`)
webpackExportEntryRe = regexp.MustCompile(`(?:^|[,\{])\s*([\w$]+)\s*:\s*\(\s*\)\s*=>\s*([\w$]+)`)
moduleImportRe = regexp.MustCompile(`(?:\b(?:var|let|const)\s+|,)\s*([\w$]+)\s*=\s*[\w$]+\(\s*(\d+)\s*\)`)
typeNameDeclarationRe = regexp.MustCompile(`(?:\bthis|[\w$]+)\.typeName\s*=\s*["']([\w.]+)["']`)
serviceDeclarationRe = regexp.MustCompile(`\{\s*typeName\s*:\s*["']([\w.]+)["']\s*,\s*methods\s*:`)
messageDeclarationRe = regexp.MustCompile(`\.makeMessageType\s*\(\s*["']([\w.]+)["']`)
enumDeclarationRe = regexp.MustCompile(`\.makeEnum\s*\(\s*["']([\w.]+)["']`)
legacyEnumDeclarationRe = regexp.MustCompile(`\.setEnumType\s*\(\s*[\w$]+\s*,\s*["']([\w.]+)["']`)
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
)
// Field 描述一个待渲染的 protobuf 字段。
type Field struct {
// No 是字段编号。
No int `json:"no"`
// Name 是字段名称。
Name string `json:"name"`
// Kind 是标量、消息、枚举或映射类别。
Kind string `json:"kind"`
// T 保存标量编号或消息引用变量。
T any `json:"T"`
// Oneof 是字段所属的互斥分组。
Oneof string `json:"oneof"`
// Repeated 表示字段可以重复。
Repeated bool `json:"repeated"`
// Opt 表示字段为显式可选。
Opt bool `json:"opt"`
// MapKey 是映射键的标量编号。
MapKey int `json:"K"`
// MapValueKind 是映射值的标量或消息类别。
MapValueKind string
// MapValueT 保存映射值的标量编号或消息引用。
MapValueT any
}
// Message 描述提取出的消息及其源码位置。
type Message struct {
// TypeName 是消息的全限定类型名。
TypeName string
// VarName 是 JS 外部变量名。
VarName string
// InternalName 是 JS 内部类名。
InternalName string
// Fields 是消息字段列表。
Fields []Field
// Package 是消息所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是消息在 bundle 中的字节位置。
Pos int
// ModuleStart 是消息所在模块的起始位置。
ModuleStart int
}
// Enum 描述提取出的枚举及其源码位置。
type Enum struct {
// TypeName 是枚举的全限定类型名。
TypeName string
// VarName 是枚举对应的 JS 变量名。
VarName string
// Values 是枚举值列表。
Values []EnumValue
// Package 是枚举所属协议包。
Package string
// ShortName 是包内嵌套类型名。
ShortName string
// Pos 是枚举在 bundle 中的字节位置。
Pos int
// ModuleStart 是枚举所在模块的起始位置。
ModuleStart int
}
// EnumValue 描述单个枚举编号和名称。
type EnumValue struct {
// No 是枚举编号。
No int
// Name 是枚举名称。
Name string
}
// Service 描述提取出的服务及其源码位置。
type Service struct {
// TypeName 是服务的全限定类型名。
TypeName string
// VarName 是服务对应的 JS 变量名。
VarName string
// Methods 是服务方法列表。
Methods []Method
// Package 是服务所属协议包。
Package string
// ShortName 是服务包内名称。
ShortName string
// Pos 是服务在 bundle 中的字节位置。
Pos int
// ModuleStart 是服务所在模块的起始位置。
ModuleStart int
}
// Method 描述一个 RPC 方法的输入、输出和流模式。
type Method struct {
// Name 是 RPC 方法名。
Name string
// InputType 是输入消息引用变量。
InputType string
// OutputType 是输出消息引用变量。
OutputType string
// Kind 是一元或不同方向的流式调用类型。
Kind string
}
// symbolDef 保存符号对应的类型、类别和模块位置。
type symbolDef struct {
// TypeName 是符号对应的全限定类型名。
TypeName string
// Pos 是符号定义位置。
Pos int
// Kind 是消息或枚举类别。
Kind string
// ModuleStart 是符号所在模块起点。
ModuleStart int
}
// TypeResolver 通过局部符号、别名和短名称解析协议类型。
type TypeResolver struct {
bySymbol map[string][]symbolDef
byAlias map[string][]symbolDef
byShort map[string][]symbolDef
moduleImports map[int]map[string]int
}
// aliasIndex 按模块和目标符号保存别名集合。
type aliasIndex map[int]map[string][]string
+15
View File
@@ -0,0 +1,15 @@
module github.com/leookun/cursor-byok/cursor-proto
go 1.25.8
require (
github.com/jhump/protoreflect v1.18.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
golang.org/x/sync v0.8.0 // indirect
)
+34
View File
@@ -0,0 +1,34 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# extract.sh 从 Cursor 安装目录安全提取 Proto 文件。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app"
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
OUTPUT_DEFAULT="$PROJECT_DIR/proto"
INPUT_ROOT="${1:-$INPUT_DEFAULT}"
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
canonicalize_path() {
local path="$1"
local parent
local base
if [[ -d "$path" ]]; then
(cd "$path" && pwd -P)
return
fi
parent="$(dirname "$path")"
base="$(basename "$path")"
if [[ ! -d "$parent" ]]; then
echo "Parent directory does not exist: $parent" >&2
return 1
fi
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
}
# 文件输入只提取自身;目录输入扫描工作台、扩展宿主和扩展产物。
INPUT_PATHS=()
add_input() {
local candidate="$1"
local existing
if [[ ! -f "$candidate" ]]; then
return 0
fi
for existing in "${INPUT_PATHS[@]-}"; do
[[ "$existing" == "$candidate" ]] && return
done
INPUT_PATHS+=("$candidate")
}
if [[ -f "$INPUT_ROOT" ]]; then
add_input "$INPUT_ROOT"
elif [[ -d "$INPUT_ROOT" ]]; then
CANDIDATES=(
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/out/vs/workbench/workbench.desktop.main.js"
"$INPUT_ROOT/workbench.desktop.main.js"
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/out/vs/workbench/api/node/extensionHostProcess.js"
"$INPUT_ROOT/extensionHostProcess.js"
"$INPUT_ROOT/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/Resources/app/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/extensions/cursor-always-local/dist/main.js"
"$INPUT_ROOT/cursor-always-local/dist/main.js"
)
for CANDIDATE in "${CANDIDATES[@]}"; do
add_input "$CANDIDATE"
done
while IFS= read -r JS_FILE; do
add_input "$JS_FILE"
done < <(find "$INPUT_ROOT" -type f ! -path "*/node_modules/*" \( -name "workbench.desktop.main.js" -o -name "extensionHostProcess.js" -o -path "*/extensions/*/dist/main.js" \) | sort)
fi
if [[ -z "${INPUT_PATHS[*]-}" ]]; then
echo "No supported Cursor JS bundle found under: $INPUT_ROOT" >&2
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
echo " $0 /path/to/Cursor.app [output-dir]" >&2
exit 1
fi
for INDEX in "${!INPUT_PATHS[@]}"; do
INPUT_PATHS[$INDEX]="$(canonicalize_path "${INPUT_PATHS[$INDEX]}")"
done
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
CURRENT_DIR="$(pwd -P)"
case "$OUTPUT_DIR" in
"/"|"$HOME"|"$PROJECT_DIR"|"$SCRIPT_DIR"|"$CURRENT_DIR")
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
exit 1
;;
esac
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
case "$INPUT_PATH" in
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
echo "Refusing output directory that contains an input bundle: $OUTPUT_DIR" >&2
exit 1
;;
esac
done
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
BACKUP_DIR=""
cleanup() {
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
if [[ ! -e "$OUTPUT_DIR" ]]; then
mv "$BACKUP_DIR" "$OUTPUT_DIR"
else
rm -rf "$BACKUP_DIR"
fi
fi
}
trap cleanup EXIT
EXTRACT_ARGS=()
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
EXTRACT_ARGS+=( -input "$INPUT_PATH" )
done
(
cd "$PROJECT_DIR"
go run ./extractor \
"${EXTRACT_ARGS[@]}" \
-output "$TEMP_DIR" \
-skip-format \
-strict
)
if [[ -e "$OUTPUT_DIR" ]]; then
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
rmdir "$BACKUP_DIR"
mv "$OUTPUT_DIR" "$BACKUP_DIR"
fi
mv "$TEMP_DIR" "$OUTPUT_DIR"
TEMP_DIR=""
if [[ -n "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
BACKUP_DIR=""
fi
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# generate.sh 根据提取的 Proto 定义生成可供其他 Go module 使用的消息包。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PROTO_DIR="$PROJECT_DIR/proto"
MODULE_PATH="github.com/leookun/cursor-byok/cursor-proto"
command -v protoc >/dev/null 2>&1 || {
echo "protoc is required" >&2
exit 1
}
command -v protoc-gen-go >/dev/null 2>&1 || {
echo "protoc-gen-go is required" >&2
exit 1
}
for PROTO_FILE in agent_v1.proto aiserver_v1.proto; do
if [[ ! -f "$PROTO_DIR/$PROTO_FILE" ]]; then
echo "Missing Proto source: $PROTO_DIR/$PROTO_FILE" >&2
exit 1
fi
done
protoc \
--proto_path="$PROTO_DIR" \
--go_out="$PROJECT_DIR" \
--go_opt="module=$MODULE_PATH" \
"$PROTO_DIR/agent_v1.proto" \
"$PROTO_DIR/aiserver_v1.proto"
echo "Generated Go packages under: $PROJECT_DIR/gen"
-70
View File
@@ -1,70 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"strings"
"cursor/internal/appdata"
"cursor/internal/historymetrics"
)
func main() {
flags := flag.NewFlagSet("historymetrics", flag.ExitOnError)
usagePath := flags.String("usage", appdata.UsageFilePath(), "usage JSON file to read")
if err := flags.Parse(os.Args[1:]); err != nil {
exitf(err.Error())
}
if flags.NArg() > 0 {
exitf("positional history scan targets are no longer supported; usage totals come from history/usage.json")
}
summary, err := historymetrics.LoadUsageSummary(*usagePath)
if err != nil {
exitf(err.Error())
}
fmt.Printf("Usage JSON file: %s\n\n", strings.TrimSpace(*usagePath))
fmt.Println("Overall")
fmt.Printf("- Provider calls total: %s\n", formatNumber(int64(summary.ProviderCallsTotal)))
fmt.Printf("- Turns total: %s\n", formatNumber(int64(summary.TurnsTotal)))
fmt.Printf("- Cache hit rate: %s\n", formatPercent(summary.CacheHitRate))
fmt.Printf("- Cache read tokens: %s\n", formatNumber(summary.CacheReadTokens))
fmt.Printf("- Cache write tokens: %s\n", formatNumber(summary.CacheWriteTokens))
fmt.Printf("- Prompt tokens total: %s\n", formatNumber(summary.PromptTokensTotal))
fmt.Printf("- Request tokens total: %s\n", formatNumber(summary.RequestTokensTotal))
}
func exitf(message string) {
fmt.Fprintf(os.Stderr, "historymetrics failed: %s\n", strings.TrimSpace(message))
os.Exit(1)
}
func formatNumber(value int64) string {
sign := ""
if value < 0 {
sign = "-"
value = -value
}
digits := fmt.Sprintf("%d", value)
if len(digits) <= 3 {
return sign + digits
}
parts := make([]string, 0, (len(digits)+2)/3)
for len(digits) > 3 {
parts = append([]string{digits[len(digits)-3:]}, parts...)
digits = digits[:len(digits)-3]
}
parts = append([]string{digits}, parts...)
return sign + strings.Join(parts, ",")
}
func formatPercent(value *float64) string {
if value == nil {
return "n/a"
}
return fmt.Sprintf("%.2f%%", *value*100)
}
@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { stat, readFile, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
const assetSpecs = [
["macos-arm64", ".tar.gz"],
["macos-amd64", ".tar.gz"],
["windows-amd64", ".zip"],
["linux-amd64", ".tar.gz"],
];
function readOptions(args) {
const options = new Map();
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
const value = args[index + 1];
if (!name?.startsWith("--") || value === undefined) {
throw new Error(`invalid argument near ${name ?? "end of command"}`);
}
options.set(name.slice(2), value);
}
return options;
}
function required(options, name) {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
async function sha256(path) {
const content = await readFile(path);
return createHash("sha256").update(content).digest("hex");
}
async function main() {
const options = readOptions(process.argv.slice(2));
const version = required(options, "version").replace(/^v/, "");
const repository = required(options, "repository");
const assetsDir = resolve(required(options, "assets-dir"));
const output = resolve(required(options, "output"));
const releaseNotes = options.get("notes")?.trim() || `Cursor BYOK v${version}`;
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`invalid semantic version: ${version}`);
}
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
throw new Error(`invalid GitHub repository: ${repository}`);
}
const platforms = {};
for (const [platform, suffix] of assetSpecs) {
const filename = `cursor-byok-${version}-${platform}${suffix}`;
const path = join(assetsDir, filename);
const info = await stat(path);
if (!info.isFile()) throw new Error(`release asset is not a file: ${path}`);
platforms[platform] = {
url: `https://github.com/${repository}/releases/download/v${version}/${basename(path)}`,
size: info.size,
checksum: `sha256:${await sha256(path)}`,
};
}
const manifest = {
version,
release_date: new Date().toISOString(),
release_notes: releaseNotes,
platforms,
mandatory: false,
};
await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
-249
View File
@@ -1,249 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type buildConfig struct {
Info struct {
Version string `yaml:"version"`
} `yaml:"info"`
}
type updateManifest struct {
Version string `json:"version"`
ReleaseDate string `json:"release_date"`
ReleaseNotes string `json:"release_notes"`
Platforms map[string]updateManifestAsset `json:"platforms"`
Mandatory bool `json:"mandatory"`
}
type updateManifestAsset struct {
URL string `json:"url"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
}
type assetSpec struct {
platform string
suffix string
}
var releaseAssets = []assetSpec{
{platform: "macos-arm64", suffix: ".tar.gz"},
{platform: "macos-amd64", suffix: ".tar.gz"},
{platform: "windows-amd64", suffix: ".zip"},
{platform: "linux-amd64", suffix: ".tar.gz"},
}
func main() {
if len(os.Args) < 2 {
exitf("usage: go run ./scripts/release <version|notes|manifest> [flags]")
}
switch os.Args[1] {
case "version":
runVersion(os.Args[2:])
case "notes":
runNotes(os.Args[2:])
case "manifest":
runManifest(os.Args[2:])
default:
exitf("unknown subcommand: %s", os.Args[1])
}
}
func runVersion(args []string) {
flags := flag.NewFlagSet("version", flag.ExitOnError)
configPath := flags.String("config", "build/config.yml", "path to build config")
_ = flags.Parse(args)
version, err := readVersion(*configPath)
if err != nil {
exitErr(err)
}
fmt.Print(version)
}
func runNotes(args []string) {
flags := flag.NewFlagSet("notes", flag.ExitOnError)
_ = flags.String("config", "build/config.yml", "path to build config")
outputPath := flags.String("out", "", "output file path")
sourcePath := flags.String("source", "", "source markdown file")
_ = flags.Parse(args)
if strings.TrimSpace(*outputPath) == "" {
exitf("notes output path is required")
}
if strings.TrimSpace(*sourcePath) == "" {
exitf("notes source path is required")
}
notes, err := resolveReleaseNotes(*sourcePath)
if err != nil {
exitErr(err)
}
if err := os.MkdirAll(filepath.Dir(*outputPath), 0o755); err != nil {
exitErr(err)
}
if err := os.WriteFile(*outputPath, []byte(notes), 0o644); err != nil {
exitErr(err)
}
}
func runManifest(args []string) {
flags := flag.NewFlagSet("manifest", flag.ExitOnError)
configPath := flags.String("config", "build/config.yml", "path to build config")
assetsDir := flags.String("assets-dir", "", "directory containing release assets")
outputPath := flags.String("out", "", "manifest output file")
repo := flags.String("repo", "", "GitHub repo in owner/repo form")
baseName := flags.String("base-name", "cursor-byok", "release asset basename")
notesPath := flags.String("notes", "", "release notes file")
_ = flags.Parse(args)
if strings.TrimSpace(*assetsDir) == "" {
exitf("assets-dir is required")
}
if strings.TrimSpace(*outputPath) == "" {
exitf("manifest output path is required")
}
if strings.TrimSpace(*repo) == "" {
exitf("repo is required")
}
if strings.TrimSpace(*notesPath) == "" {
exitf("notes is required")
}
version, err := readVersion(*configPath)
if err != nil {
exitErr(err)
}
notes, err := resolveReleaseNotes(*notesPath)
if err != nil {
exitErr(err)
}
manifest := updateManifest{
Version: version,
ReleaseDate: time.Now().UTC().Format(time.RFC3339),
ReleaseNotes: notes,
Platforms: map[string]updateManifestAsset{},
Mandatory: false,
}
for _, spec := range releaseAssets {
filename := fmt.Sprintf("%s-%s-%s%s", *baseName, version, spec.platform, spec.suffix)
fullpath := filepath.Join(*assetsDir, filename)
asset, err := buildManifestAsset(fullpath, *repo, version, filename)
if err != nil {
exitErr(err)
}
manifest.Platforms[spec.platform] = asset
}
if err := os.MkdirAll(filepath.Dir(*outputPath), 0o755); err != nil {
exitErr(err)
}
content, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
exitErr(err)
}
content = append(content, '\n')
if err := os.WriteFile(*outputPath, content, 0o644); err != nil {
exitErr(err)
}
}
func readVersion(configPath string) (string, error) {
content, err := os.ReadFile(configPath)
if err != nil {
return "", err
}
var cfg buildConfig
if err := yaml.Unmarshal(content, &cfg); err != nil {
return "", err
}
version := strings.TrimSpace(strings.TrimPrefix(cfg.Info.Version, "v"))
if version == "" {
return "", errors.New("build/config.yml info.version is empty")
}
return version, nil
}
func resolveReleaseNotes(sourcePath string) (string, error) {
candidate := strings.TrimSpace(sourcePath)
if candidate == "" {
return "", errors.New("release notes source path is required")
}
content, err := os.ReadFile(candidate)
if err != nil {
return "", err
}
notes := strings.TrimSpace(string(content))
if notes == "" {
return "", fmt.Errorf("release notes file %s is empty", candidate)
}
return notes, nil
}
func buildManifestAsset(path, repo, version, filename string) (updateManifestAsset, error) {
info, err := os.Stat(path)
if err != nil {
return updateManifestAsset{}, err
}
checksum, err := sha256File(path)
if err != nil {
return updateManifestAsset{}, err
}
return updateManifestAsset{
URL: fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, filename),
Size: info.Size(),
Checksum: "sha256:" + checksum,
}, nil
}
func sha256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func exitErr(err error) {
exitf("%v", err)
}
func exitf(format string, args ...any) {
_, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}