mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 11:37:20 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e265d415e | ||
|
|
0d1cedda5b | ||
|
|
c7fe198110 | ||
|
|
8c4baf7ade | ||
|
|
4e0d2ee703 | ||
|
|
e1dacab268 | ||
|
|
320b9fb7b9 | ||
|
|
c55c575a58 | ||
|
|
d3adfffbd8 | ||
|
|
4e9335d82f | ||
|
|
374ff9c217 | ||
|
|
06ab0d8dae | ||
|
|
2f47f02497 | ||
|
|
058aaa532e | ||
|
|
622a57fed7 | ||
|
|
f5a7347f44 | ||
|
|
a9b406ec30 | ||
|
|
639c452a00 | ||
|
|
0c9a07e018 | ||
|
|
1adca7f600 | ||
|
|
5df3e8f512 | ||
|
|
a2e3a07773 | ||
|
|
6b7c077448 | ||
|
|
c64911e8c6 | ||
|
|
d1d56bd6eb | ||
|
|
ee47f187f0 | ||
|
|
424235f11d | ||
|
|
58770aa206 | ||
|
|
eb0ad7b636 | ||
|
|
697fa99245 | ||
|
|
1854a3ab9a | ||
|
|
2c650b3765 | ||
|
|
14b286a466 | ||
|
|
828e0a9941 |
@@ -23,6 +23,29 @@ description: 本地模式实现指南
|
||||
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
||||
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
|
||||
|
||||
## 可选抓包调试工具
|
||||
|
||||
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
|
||||
|
||||
- `agent.v1.AgentService/RunSSE`
|
||||
- `aiserver.v1.BidiService/BidiAppend`
|
||||
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
|
||||
- 同一 `request_id` 对应的上下行消息
|
||||
|
||||
开发者启动工具后,需要自行完成以下配置:
|
||||
|
||||
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。
|
||||
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
|
||||
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||
|
||||
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
|
||||
|
||||
## Cursor 客户端格式化快照
|
||||
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
||||
@@ -319,3 +342,15 @@ description: 本地模式实现指南
|
||||
- `PendingInteraction`
|
||||
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
||||
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
||||
|
||||
### 5.1 checkpoint 投影必须幂等且只有一个事实源
|
||||
|
||||
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
|
||||
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
|
||||
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
|
||||
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
|
||||
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
|
||||
- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
|
||||
- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。
|
||||
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
|
||||
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
|
||||
|
||||
@@ -15,6 +15,7 @@ server-go/log/
|
||||
.cursor-local-assistant
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
proto/extensions-cursor-app/
|
||||
ads-server-linux-amd64.tar
|
||||
cmd/ads-server/*.db
|
||||
cmd/ads-server/*.db-*
|
||||
|
||||
@@ -20,7 +20,7 @@ https://t.me/cursor_byok
|
||||
## 路线图
|
||||
|
||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
[详细使用教程](https://dcne38qm5vlg.feishu.cn/wiki/JeP7wdGnziBXuikNaF5czWbrn8c)
|
||||
[详细使用教程](https://docs.leokun.cn)
|
||||
|
||||
## 后续
|
||||
|
||||
@@ -37,13 +37,3 @@ https://t.me/cursor_byok
|
||||
|
||||
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -178,6 +178,16 @@ tasks:
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
proxy-debugger:
|
||||
summary: 启动独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go run ./cmd/cursor-proxy-debugger
|
||||
|
||||
proxy-debugger:build:
|
||||
summary: 构建独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
|
||||
ads:install:
|
||||
summary: 安装广告页依赖
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
|
||||
+9
-2
@@ -13,8 +13,8 @@ tasks:
|
||||
preconditions:
|
||||
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
|
||||
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
|
||||
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||
msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||
cmds:
|
||||
- chmod +x ./proto/extract_extensions_proto.sh
|
||||
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
|
||||
@@ -29,9 +29,16 @@ tasks:
|
||||
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
|
||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
|
||||
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
|
||||
- ./proto/check_proto_sync.sh
|
||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||
- task: generate:proto
|
||||
|
||||
check:proto:
|
||||
summary: 检查根 proto 与扩展提取快照是否一致
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- ./proto/check_proto_sync.sh
|
||||
|
||||
generate:proto:
|
||||
summary: 生成 proto Go/Connect 代码
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ info:
|
||||
description: "Cursor助手"
|
||||
copyright: "© 2026, Cursor助手"
|
||||
comments: "Cursor助手"
|
||||
version: "0.0.43"
|
||||
version: "0.0.45"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.43</string>
|
||||
<string>0.0.45</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.43</string>
|
||||
<string>0.0.45</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.43</string>
|
||||
<string>0.0.45</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.43</string>
|
||||
<string>0.0.45</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
name: "Cursor助手"
|
||||
arch: ${GOARCH}
|
||||
platform: "linux"
|
||||
version: "0.0.43"
|
||||
version: "0.0.45"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.43"
|
||||
"file_version": "0.0.45"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.43",
|
||||
"ProductVersion": "0.0.45",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.43"
|
||||
!define INFO_PRODUCTVERSION "0.0.45"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.43" processorArchitecture="*"/>
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.45" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
proxydebugger "cursor/cursor-proxy-debugger"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := proxydebugger.Config{}
|
||||
openBrowser := true
|
||||
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
|
||||
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
|
||||
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
|
||||
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||
flag.Parse()
|
||||
|
||||
server, err := proxydebugger.New(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Cursor 协议调试代理已启动\n")
|
||||
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
|
||||
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||
if openBrowser {
|
||||
_ = browser.OpenURL(server.UIURL())
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signals
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Close(shutdownContext); err != nil {
|
||||
log.Printf("关闭调试代理失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Cursor Protocol Debugger
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
|
||||
|
||||
## Start
|
||||
|
||||
Run the following command from the repository root:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
Default addresses:
|
||||
|
||||
- HTTP/HTTPS proxy: `127.0.0.1:9090`
|
||||
- Debugging UI: `http://127.0.0.1:9091`
|
||||
- MITM target: `api2.cursor.sh`
|
||||
|
||||
The debugging UI opens automatically after startup.
|
||||
|
||||
## Configure Cursor
|
||||
|
||||
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
|
||||
|
||||
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
|
||||
2. Open Cursor's Network settings and enable HTTP/1.1.
|
||||
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
|
||||
|
||||
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```text
|
||||
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
|
||||
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
|
||||
-target-host Host to decrypt and capture; default: api2.cursor.sh
|
||||
-max-exchanges Maximum number of exchanges retained in memory; default: 200
|
||||
-open Open the browser after startup; default: true
|
||||
```
|
||||
|
||||
## Data Handling
|
||||
|
||||
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
|
||||
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
||||
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
||||
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
|
||||
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
|
||||
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
|
||||
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
|
||||
- Captured traffic is stored only in process memory and is discarded when the process exits.
|
||||
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
|
||||
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Cursor 协议调试器
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend`、`RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
|
||||
|
||||
## 启动
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
默认监听:
|
||||
|
||||
- HTTP/HTTPS 代理:`127.0.0.1:9090`
|
||||
- 调试界面:`http://127.0.0.1:9091`
|
||||
- MITM 目标:`api2.cursor.sh`
|
||||
|
||||
启动后会自动打开调试界面。
|
||||
|
||||
## 配置 Cursor
|
||||
|
||||
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
|
||||
|
||||
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`。
|
||||
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
|
||||
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||
|
||||
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
```text
|
||||
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
|
||||
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
|
||||
-target-host 需要解密的目标主机,默认 api2.cursor.sh
|
||||
-max-exchanges 内存中保留的最大请求数,默认 200
|
||||
-open 启动后是否打开浏览器,默认 true
|
||||
```
|
||||
|
||||
## 数据处理
|
||||
|
||||
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
|
||||
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||
- Fork Chat 相关的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。
|
||||
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone` 和 `UploadConversationBlobs` 流量。
|
||||
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
|
||||
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
|
||||
- 抓包只保留在当前进程内存中;关闭进程后消失。
|
||||
- `Authorization`、`Cookie`、`Set-Cookie` 等 HTTP 头在界面中默认隐藏。
|
||||
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
|
||||
@@ -0,0 +1,88 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (reader *captureReadCloser) Close() error {
|
||||
err := reader.source.Close()
|
||||
reader.finish(err)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func rawHex(payload []byte) string {
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
agentprotocol "cursor/internal/backend/agent/protocol"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const maxConnectFrameBytes = 64 << 20
|
||||
|
||||
const (
|
||||
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
||||
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
||||
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
||||
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
||||
)
|
||||
|
||||
type connectFrameDecoder struct {
|
||||
buffer []byte
|
||||
messageType string
|
||||
codec string
|
||||
maxFrames int
|
||||
frameCount int
|
||||
onFrame func(FrameView)
|
||||
}
|
||||
|
||||
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
|
||||
return &connectFrameDecoder{
|
||||
messageType: messageType,
|
||||
codec: strings.TrimSpace(codec),
|
||||
maxFrames: maxFrames,
|
||||
onFrame: onFrame,
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (decoder *connectFrameDecoder) emit(frame FrameView) {
|
||||
frame.Index = decoder.frameCount
|
||||
decoder.frameCount++
|
||||
if decoder.onFrame != nil {
|
||||
decoder.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID 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 := agentprotocol.DecodeAgentClientMessage(request.GetData())
|
||||
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, marshalErr
|
||||
}
|
||||
message, kind := unaryRequestMessage(path)
|
||||
if message == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, "", nil
|
||||
}
|
||||
|
||||
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
|
||||
message, kind := unaryResponseMessage(path)
|
||||
if message == nil {
|
||||
return "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, nil
|
||||
}
|
||||
|
||||
func unaryRequestMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
func unaryResponseMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
func decodesUnaryRequest(path string) bool {
|
||||
if path == bidiAppendPath {
|
||||
return true
|
||||
}
|
||||
message, _ := unaryRequestMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
func decodesUnaryResponse(path string) bool {
|
||||
message, _ := unaryResponseMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
func newMessage(messageType string) proto.Message {
|
||||
switch messageType {
|
||||
case "aiserver.v1.BidiRequestId":
|
||||
return &aiserverv1.BidiRequestId{}
|
||||
case "agent.v1.AgentServerMessage":
|
||||
return &agentv1.AgentServerMessage{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func clippedHex(payload []byte, max int) string {
|
||||
if len(payload) > max {
|
||||
return hex.EncodeToString(payload[:max]) + "..."
|
||||
}
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDecodeForkTrafficRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
message proto.Message
|
||||
kind string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "fork background composer",
|
||||
path: forkBackgroundComposerPath,
|
||||
message: &aiserverv1.ForkBackgroundComposerRequest{
|
||||
SourceBcId: "bc-source",
|
||||
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||
Name: proto.String("forked chat"),
|
||||
TurnCount: proto.Uint32(4),
|
||||
},
|
||||
kind: "fork_background_composer_request",
|
||||
contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`},
|
||||
},
|
||||
{
|
||||
name: "notify conversation clone",
|
||||
path: notifyConversationClonePath,
|
||||
message: &agentv1.NotifyConversationCloneRequest{
|
||||
ConversationId: "new-conversation",
|
||||
SourceConversationId: "source-conversation",
|
||||
SourceRequestId: "source-request",
|
||||
},
|
||||
kind: "notify_conversation_clone_request",
|
||||
contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`},
|
||||
},
|
||||
{
|
||||
name: "upload conversation blobs",
|
||||
path: uploadConversationBlobsPath,
|
||||
message: &agentv1.UploadConversationBlobsRequest{
|
||||
ConversationId: "new-conversation",
|
||||
Blobs: []*agentv1.BlobEntry{{
|
||||
Id: []byte{1, 2},
|
||||
Value: []byte("blob-value"),
|
||||
}},
|
||||
ChunkIndex: 1,
|
||||
TotalChunks: 2,
|
||||
},
|
||||
kind: "upload_conversation_blobs_request",
|
||||
contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := proto.Marshal(test.message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if kind != test.kind {
|
||||
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||
}
|
||||
if requestID != "" {
|
||||
t.Fatalf("request ID = %q, want empty", requestID)
|
||||
}
|
||||
compact := compactJSON(t, decoded)
|
||||
for _, expected := range test.contains {
|
||||
if !strings.Contains(compact, expected) {
|
||||
t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeForkTrafficResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
message proto.Message
|
||||
kind string
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "fork background composer",
|
||||
path: forkBackgroundComposerPath,
|
||||
message: &aiserverv1.ForkBackgroundComposerResponse{
|
||||
BcId: "bc-fork",
|
||||
SourceBcId: "bc-source",
|
||||
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||
},
|
||||
kind: "fork_background_composer_response",
|
||||
contains: `"bc_id":"bc-fork"`,
|
||||
},
|
||||
{
|
||||
name: "notify conversation clone",
|
||||
path: notifyConversationClonePath,
|
||||
message: &agentv1.NotifyConversationCloneResponse{},
|
||||
kind: "notify_conversation_clone_response",
|
||||
contains: `{}`,
|
||||
},
|
||||
{
|
||||
name: "upload conversation blobs",
|
||||
path: uploadConversationBlobsPath,
|
||||
message: &agentv1.UploadConversationBlobsResponse{},
|
||||
kind: "upload_conversation_blobs_response",
|
||||
contains: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := proto.Marshal(test.message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, kind, err := decodeUnaryResponse(test.path, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if kind != test.kind {
|
||||
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, decoded), test.contains) {
|
||||
t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{
|
||||
BcId: "bc-fork",
|
||||
SourceBcId: "bc-source",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: newExchangeStore(1)}
|
||||
server.store.create(&Exchange{
|
||||
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||
})
|
||||
server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||
|
||||
exchange, ok := server.store.get("1")
|
||||
if !ok {
|
||||
t.Fatal("exchange was not stored")
|
||||
}
|
||||
if exchange.ResponseKind != "fork_background_composer_response" {
|
||||
t.Fatalf("response kind = %q", exchange.ResponseKind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) {
|
||||
t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON)
|
||||
}
|
||||
if exchange.Response.DecodeError != "" {
|
||||
t.Fatalf("decode error = %q", exchange.Response.DecodeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{
|
||||
ConversationId: "new-conversation",
|
||||
SourceConversationId: "source-conversation",
|
||||
SourceRequestId: "source-request",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: newExchangeStore(1)}
|
||||
server.store.create(&Exchange{
|
||||
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||
})
|
||||
server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||
|
||||
exchange, ok := server.store.get("1")
|
||||
if !ok {
|
||||
t.Fatal("exchange was not stored")
|
||||
}
|
||||
if exchange.RequestKind != "notify_conversation_clone_request" {
|
||||
t.Fatalf("request kind = %q", exchange.RequestKind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) {
|
||||
t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON)
|
||||
}
|
||||
if exchange.Request.DecodeError != "" {
|
||||
t.Fatalf("decode error = %q", exchange.Request.DecodeError)
|
||||
}
|
||||
}
|
||||
|
||||
func compactJSON(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, []byte(value)); err != nil {
|
||||
t.Fatalf("compact JSON: %v\n%s", err, value)
|
||||
}
|
||||
return compact.String()
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
)
|
||||
|
||||
type exchangeContext struct {
|
||||
id string
|
||||
}
|
||||
|
||||
// Server runs the HTTPS debugging proxy and its local web UI.
|
||||
type Server struct {
|
||||
config Config
|
||||
certManager *certs.Manager
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
proxyServer *http.Server
|
||||
uiServer *http.Server
|
||||
proxyLn net.Listener
|
||||
uiLn net.Listener
|
||||
runMu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a standalone Cursor protocol debugger.
|
||||
func New(config Config) (*Server, error) {
|
||||
config = config.normalized()
|
||||
if err := validateLoopbackAddress(config.UIAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager, err := certs.NewEmbeddedManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
certManager: manager,
|
||||
store: newExchangeStore(config.MaxExchanges),
|
||||
}
|
||||
proxyHandler, err := server.newProxyHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.proxyServer = &http.Server{
|
||||
Handler: proxyHandler,
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
server.uiServer = &http.Server{Handler: server.newUIHandler()}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start starts both listeners without modifying Cursor or system proxy settings.
|
||||
func (server *Server) Start() error {
|
||||
server.runMu.Lock()
|
||||
defer server.runMu.Unlock()
|
||||
if server.proxyLn != nil || server.uiLn != nil {
|
||||
return errors.New("调试代理已经启动")
|
||||
}
|
||||
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动代理监听失败:%w", err)
|
||||
}
|
||||
uiListener, err := net.Listen("tcp", server.config.UIAddr)
|
||||
if err != nil {
|
||||
_ = proxyListener.Close()
|
||||
return fmt.Errorf("启动调试界面失败:%w", err)
|
||||
}
|
||||
server.proxyLn = proxyListener
|
||||
server.uiLn = uiListener
|
||||
go func() { _ = server.proxyServer.Serve(proxyListener) }()
|
||||
go func() { _ = server.uiServer.Serve(uiListener) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops both listeners.
|
||||
func (server *Server) Close(ctx context.Context) error {
|
||||
server.runMu.Lock()
|
||||
proxyServer := server.proxyServer
|
||||
uiServer := server.uiServer
|
||||
server.proxyLn = nil
|
||||
server.uiLn = nil
|
||||
server.runMu.Unlock()
|
||||
var errorsList []error
|
||||
if proxyServer != nil {
|
||||
if err := proxyServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
if uiServer != nil {
|
||||
if err := uiServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errorsList...)
|
||||
}
|
||||
|
||||
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
|
||||
func (server *Server) UIAddr() string { return server.config.UIAddr }
|
||||
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
|
||||
|
||||
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
proxy.Verbose = false
|
||||
proxy.AllowHTTP2 = true
|
||||
proxy.Logger = log.New(io.Discard, "", 0)
|
||||
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
|
||||
id := exchangeID(context)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = connectionErr.Error()
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
})
|
||||
}
|
||||
proxy.Tr = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 200,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
caCertificate, err := server.certManager.CATLSCertificate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
|
||||
}
|
||||
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
|
||||
mitmAction := &goproxy.ConnectAction{
|
||||
Action: goproxy.ConnectMitm,
|
||||
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
|
||||
return baseTLSConfig(host, context)
|
||||
},
|
||||
}
|
||||
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||
if server.matchesTargetHost(host) {
|
||||
return mitmAction, host
|
||||
}
|
||||
return goproxy.OkConnect, host
|
||||
})
|
||||
|
||||
proxy.OnRequest().DoFunc(server.captureRequest)
|
||||
proxy.OnResponse().DoFunc(server.captureResponse)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
|
||||
return request, nil
|
||||
}
|
||||
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||
path := request.URL.Path
|
||||
requestCodec := requestContentCodec(path, request.Header)
|
||||
exchange := &Exchange{
|
||||
ExchangeSummary: ExchangeSummary{
|
||||
ID: id,
|
||||
StartedAt: time.Now(),
|
||||
Method: request.Method,
|
||||
URL: request.URL.String(),
|
||||
Host: request.URL.Host,
|
||||
Path: path,
|
||||
State: "pending",
|
||||
},
|
||||
Request: Payload{
|
||||
Headers: sortedHeaders(request.Header),
|
||||
ContentType: request.Header.Get("Content-Type"),
|
||||
ContentCodec: requestCodec,
|
||||
Frames: make([]FrameView, 0),
|
||||
},
|
||||
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||
}
|
||||
server.store.create(exchange)
|
||||
context.UserData = exchangeContext{id: id}
|
||||
|
||||
if request.Body == nil {
|
||||
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
|
||||
return request, nil
|
||||
}
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"aiserver.v1.BidiRequestId",
|
||||
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, requestCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
|
||||
id := exchangeID(context)
|
||||
if id == "" || response == nil {
|
||||
return response
|
||||
}
|
||||
path := ""
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
path = response.Request.URL.Path
|
||||
}
|
||||
responseCodec := responseContentCodec(path, response.Header)
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.Status = response.StatusCode
|
||||
exchange.State = "streaming"
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
||||
exchange.Response.ContentCodec = responseCodec
|
||||
})
|
||||
if response.Body == nil {
|
||||
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
|
||||
return response
|
||||
}
|
||||
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"agent.v1.AgentServerMessage",
|
||||
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, responseCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryRequest(path) && truncated {
|
||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||
}
|
||||
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
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.RequestKind = kind
|
||||
}
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Request.DecodeError = decodeErr.Error()
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requestContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func responseContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
if !decodesUnaryResponse(path) {
|
||||
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||
return codec
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryResponse(path) && truncated {
|
||||
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.ResponseBytes = size
|
||||
exchange.Response.Size = size
|
||||
exchange.Response.RawHex = rawHex(captured)
|
||||
exchange.Response.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Response.DecodedJSON = decodedJSON
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.ResponseKind = kind
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Response.DecodeError = decodeErr.Error()
|
||||
}
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.State = "completed"
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||
server.store.update(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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||
server.store.update(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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
host := request.Host
|
||||
if request.URL != nil && request.URL.Host != "" {
|
||||
host = request.URL.Host
|
||||
}
|
||||
return server.matchesTargetHost(host)
|
||||
}
|
||||
|
||||
func (server *Server) matchesTargetHost(host string) bool {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
|
||||
return host == target
|
||||
}
|
||||
|
||||
func exchangeID(context *goproxy.ProxyCtx) string {
|
||||
if context == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := context.UserData.(exchangeContext)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return value.id
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type exchangeStore struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
order []string
|
||||
exchanges map[string]*Exchange
|
||||
subscribers map[chan storeEvent]struct{}
|
||||
}
|
||||
|
||||
func newExchangeStore(max int) *exchangeStore {
|
||||
return &exchangeStore{
|
||||
max: max,
|
||||
exchanges: make(map[string]*Exchange),
|
||||
subscribers: make(map[chan storeEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
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.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "created", ID: exchange.ID})
|
||||
}
|
||||
|
||||
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
|
||||
store.mu.Lock()
|
||||
if exchange := store.exchanges[id]; exchange != nil {
|
||||
apply(exchange)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "updated", ID: id})
|
||||
}
|
||||
|
||||
func (store *exchangeStore) summaries() []ExchangeSummary {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
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
|
||||
}
|
||||
|
||||
func (store *exchangeStore) get(id string) (Exchange, bool) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange == nil {
|
||||
return Exchange{}, false
|
||||
}
|
||||
return cloneExchange(*exchange), true
|
||||
}
|
||||
|
||||
func (store *exchangeStore) clear() {
|
||||
store.mu.Lock()
|
||||
store.order = nil
|
||||
store.exchanges = make(map[string]*Exchange)
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "cleared"})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func (store *exchangeStore) publish(event storeEvent) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
for subscriber := range store.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExchange(exchange Exchange) Exchange {
|
||||
exchange.Request = clonePayload(exchange.Request)
|
||||
exchange.Response = clonePayload(exchange.Response)
|
||||
return exchange
|
||||
}
|
||||
|
||||
func clonePayload(payload Payload) Payload {
|
||||
payload.Headers = append([]Header(nil), payload.Headers...)
|
||||
payload.Frames = append([]FrameView(nil), payload.Frames...)
|
||||
return payload
|
||||
}
|
||||
|
||||
func elapsedMS(startedAt time.Time) int64 {
|
||||
if startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func isSensitiveHeader(name string) bool {
|
||||
switch httpCanonicalLower(name) {
|
||||
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package proxydebugger
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
defaultProxyAddr = "127.0.0.1:9090"
|
||||
defaultUIAddr = "127.0.0.1:9091"
|
||||
defaultTargetHost = "api2.cursor.sh"
|
||||
defaultMaxExchanges = 200
|
||||
defaultMaxCaptureBytes = 2 << 20
|
||||
defaultMaxFrames = 2000
|
||||
)
|
||||
|
||||
// Config controls the standalone proxy debugger.
|
||||
type Config struct {
|
||||
ProxyAddr string
|
||||
UIAddr string
|
||||
TargetHost string
|
||||
MaxExchanges int
|
||||
MaxCaptureBytes int
|
||||
MaxFrames int
|
||||
}
|
||||
|
||||
func (config Config) normalized() Config {
|
||||
if config.ProxyAddr == "" {
|
||||
config.ProxyAddr = defaultProxyAddr
|
||||
}
|
||||
if config.UIAddr == "" {
|
||||
config.UIAddr = defaultUIAddr
|
||||
}
|
||||
if config.TargetHost == "" {
|
||||
config.TargetHost = defaultTargetHost
|
||||
}
|
||||
if config.MaxExchanges <= 0 {
|
||||
config.MaxExchanges = defaultMaxExchanges
|
||||
}
|
||||
if config.MaxCaptureBytes <= 0 {
|
||||
config.MaxCaptureBytes = defaultMaxCaptureBytes
|
||||
}
|
||||
if config.MaxFrames <= 0 {
|
||||
config.MaxFrames = defaultMaxFrames
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// ExchangeSummary is the compact request-list representation.
|
||||
type ExchangeSummary struct {
|
||||
ID string `json:"id"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
State string `json:"state"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
RequestKind string `json:"requestKind,omitempty"`
|
||||
ResponseKind string `json:"responseKind,omitempty"`
|
||||
FrameCount int `json:"frameCount"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Exchange contains the request and response detail shown by the debugger.
|
||||
type Exchange struct {
|
||||
ExchangeSummary
|
||||
Request Payload `json:"request"`
|
||||
Response Payload `json:"response"`
|
||||
}
|
||||
|
||||
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
|
||||
type Payload struct {
|
||||
Headers []Header `json:"headers"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
ContentCodec string `json:"contentCodec,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
RawTruncated bool `json:"rawTruncated,omitempty"`
|
||||
DecodedJSON string `json:"decodedJson,omitempty"`
|
||||
DecodeError string `json:"decodeError,omitempty"`
|
||||
Frames []FrameView `json:"frames,omitempty"`
|
||||
}
|
||||
|
||||
// Header is a stable, sorted HTTP header pair.
|
||||
type Header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// FrameView describes one Connect streaming envelope.
|
||||
type FrameView struct {
|
||||
Index int `json:"index"`
|
||||
Flags uint8 `json:"flags"`
|
||||
Length int `json:"length"`
|
||||
Compressed bool `json:"compressed"`
|
||||
EndStream bool `json:"endStream"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
MessageType string `json:"messageType,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
JSON string `json:"json,omitempty"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type storeEvent struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webAssets embed.FS
|
||||
|
||||
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("DELETE /api/exchanges", server.handleClearExchanges)
|
||||
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
|
||||
assets, _ := fs.Sub(webAssets, "web")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/", fileServer)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]any{
|
||||
"proxyAddr": server.config.ProxyAddr,
|
||||
"uiAddr": server.config.UIAddr,
|
||||
"targetHost": server.config.TargetHost,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, server.store.summaries())
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||
id := strings.TrimSpace(request.PathValue("id"))
|
||||
exchange, ok := server.store.get(id)
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, exchange)
|
||||
}
|
||||
|
||||
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||
server.store.clear()
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||
_, _ = writer.Write(certs.EmbeddedCACertPEM())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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'; style-src 'self'; connect-src 'self'")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
|
||||
|
||||
const state = {
|
||||
status: null,
|
||||
exchanges: [],
|
||||
selectedId: null,
|
||||
selected: null,
|
||||
search: "",
|
||||
requestId: "",
|
||||
endpoint: "all",
|
||||
sortOrder: "desc",
|
||||
paused: false,
|
||||
pendingRefresh: false,
|
||||
connection: { connected: false, key: "status.connecting", values: {} },
|
||||
tabs: {
|
||||
request: "body",
|
||||
response: "frames",
|
||||
},
|
||||
};
|
||||
|
||||
const elements = {
|
||||
statusDot: document.querySelector("#status-dot"),
|
||||
statusText: document.querySelector("#status-text"),
|
||||
proxyAddress: document.querySelector("#proxy-address"),
|
||||
targetHost: document.querySelector("#target-host"),
|
||||
connectionLabel: document.querySelector("#connection-label"),
|
||||
trafficSummary: document.querySelector("#traffic-summary"),
|
||||
searchInput: document.querySelector("#search-input"),
|
||||
requestIdInput: document.querySelector("#request-id-input"),
|
||||
endpointFilter: document.querySelector("#endpoint-filter"),
|
||||
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.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
|
||||
elements.targetHost.textContent = state.status.targetHost;
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
state.exchanges = await fetchJSON("/api/exchanges");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
||||
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
||||
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
|
||||
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
|
||||
if (!query) return true;
|
||||
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
|
||||
.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 isForkTrafficPath(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
|
||||
normalized.includes(endpoint),
|
||||
);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const exchanges = filteredExchanges();
|
||||
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
|
||||
elements.requestList.innerHTML = exchanges
|
||||
.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("");
|
||||
}
|
||||
|
||||
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) {
|
||||
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.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>`;
|
||||
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
|
||||
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
|
||||
}
|
||||
|
||||
function renderDetailError(error) {
|
||||
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
}
|
||||
|
||||
function renderPayload(payload, tab) {
|
||||
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
|
||||
if (tab === "headers") return renderHeaders(payload.headers);
|
||||
if (tab === "frames") return renderFrames(payload.frames);
|
||||
if (tab === "raw") {
|
||||
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
|
||||
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
|
||||
}
|
||||
if (payload.decodedJson) {
|
||||
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
|
||||
}
|
||||
if (payload.frames?.length) return renderFrames(payload.frames);
|
||||
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
|
||||
return `<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 renderFrames(frames = []) {
|
||||
const items = Array.isArray(frames) ? frames : [];
|
||||
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
|
||||
return `<div class="frame-list">${items
|
||||
.map((frame) => {
|
||||
const kind = frame.kind || frame.messageType || t("notices.unknown");
|
||||
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
|
||||
const content = frame.json
|
||||
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
|
||||
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
|
||||
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
|
||||
<summary>
|
||||
<span class="frame-index">#${frame.index}</span>
|
||||
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
|
||||
<span class="frame-size">${formatBytes(frame.length)}</span>
|
||||
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
|
||||
</summary>
|
||||
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
|
||||
</details>`;
|
||||
})
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderDecodeError(error) {
|
||||
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
|
||||
}
|
||||
|
||||
function renderTruncated(truncated) {
|
||||
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
|
||||
}
|
||||
|
||||
function formatState(value) {
|
||||
const key = {
|
||||
pending: "state.pending",
|
||||
streaming: "state.streaming",
|
||||
completed: "state.completed",
|
||||
error: "state.error",
|
||||
}[value];
|
||||
return key ? t(key) : value || "-";
|
||||
}
|
||||
|
||||
function currentCopyText(side) {
|
||||
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 || "";
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const milliseconds = Number(value || 0);
|
||||
if (milliseconds < 1000) return `${milliseconds} ms`;
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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.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);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
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);
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = t("actions.copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = t("actions.copy");
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPauseState() {
|
||||
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));
|
||||
}
|
||||
|
||||
elements.pauseButton.addEventListener("click", async () => {
|
||||
state.paused = !state.paused;
|
||||
elements.pauseButton.classList.toggle("active", state.paused);
|
||||
renderPauseState();
|
||||
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
|
||||
if (!state.paused && state.pendingRefresh) {
|
||||
state.pendingRefresh = false;
|
||||
await refreshList();
|
||||
}
|
||||
});
|
||||
|
||||
function applyLocale() {
|
||||
translateDocument();
|
||||
elements.localeSelect.value = getLocale();
|
||||
renderRuntimeStatus();
|
||||
renderConnectionState();
|
||||
renderPauseState();
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
elements.localeSelect.addEventListener("change", (event) => {
|
||||
setLocale(event.target.value);
|
||||
applyLocale();
|
||||
});
|
||||
|
||||
elements.clearButton.addEventListener("click", async () => {
|
||||
await fetchJSON("/api/exchanges", { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
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");
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
applyLocale();
|
||||
renderDetail();
|
||||
try {
|
||||
await Promise.all([loadStatus(), refreshList()]);
|
||||
connectEvents();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.connectFailed", { message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,180 @@
|
||||
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.downloadCA": "下载代理 CA 证书",
|
||||
"actions.caCertificate": "CA 证书",
|
||||
"actions.pause": "暂停界面更新",
|
||||
"actions.resume": "继续界面更新",
|
||||
"actions.clear": "清空",
|
||||
"actions.copy": "复制",
|
||||
"actions.copied": "已复制",
|
||||
"language.label": "界面语言",
|
||||
"filters.region": "请求过滤器",
|
||||
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
|
||||
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||
"filters.endpoint": "接口过滤",
|
||||
"filters.all": "全部",
|
||||
"filters.fork": "Fork",
|
||||
"filters.sort": "排序方向",
|
||||
"filters.ascending": "正序",
|
||||
"filters.descending": "倒序",
|
||||
"count.requests": "{count} 条",
|
||||
"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": "Proxy running",
|
||||
"status.stopped": "Proxy stopped",
|
||||
"actions.downloadCA": "Download proxy CA certificate",
|
||||
"actions.caCertificate": "CA Certificate",
|
||||
"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.endpoint": "Endpoint filter",
|
||||
"filters.all": "All",
|
||||
"filters.fork": "Fork",
|
||||
"filters.sort": "Sort order",
|
||||
"filters.ascending": "Oldest first",
|
||||
"filters.descending": "Newest first",
|
||||
"count.requests": "{count} requests",
|
||||
"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;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!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="proxy-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>
|
||||
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
|
||||
</div>
|
||||
<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" data-i18n="tabs.body">正文</button>
|
||||
<button type="button" data-tab="frames" class="active" 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="target-host"></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,921 @@
|
||||
: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 46px minmax(0, 1fr) 26px;
|
||||
min-width: 760px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.filterbar,
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-color: var(--border);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.runtime-status,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--surface-1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 240px;
|
||||
color: #bec3c0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-status code {
|
||||
overflow: hidden;
|
||||
max-width: 260px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #7d8380;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #42bd79;
|
||||
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.locale-picker {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.locale-picker select {
|
||||
width: 58px;
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
padding: 0 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.button,
|
||||
.icon-button,
|
||||
.copy-button {
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.icon-button:hover,
|
||||
.copy-button:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
border-color: #744640;
|
||||
color: #f2b0aa;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 31px;
|
||||
padding: 0;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-button.active {
|
||||
border-color: var(--orange);
|
||||
color: #f1bb98;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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 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);
|
||||
}
|
||||
|
||||
.detail-pane {
|
||||
display: grid;
|
||||
grid-template-rows: 38px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #1b1d1d;
|
||||
}
|
||||
|
||||
.selection-summary code {
|
||||
overflow: hidden;
|
||||
color: #aeb4b1;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge,
|
||||
.status-badge,
|
||||
.frame-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 7px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
border-color: #34667a;
|
||||
color: #74c8e8;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
border-color: #3f7157;
|
||||
color: #83d5a5;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 36px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.payload-panel:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #202222;
|
||||
}
|
||||
|
||||
.panel-header > strong {
|
||||
padding: 0 10px;
|
||||
color: #c9cdca;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
border: 0;
|
||||
padding: 0 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: #d7dad8;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: #71c7e2;
|
||||
}
|
||||
|
||||
.tabs button.active::after {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
height: 2px;
|
||||
background: var(--cyan);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
margin-right: 7px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #181a1a;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 48px 84px 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 122px 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;
|
||||
}
|
||||
|
||||
#target-host {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { computed, onBeforeUnmount, nextTick, ref, watch, watchPostEffect } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: { type: String, default: "请选择" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
summaryFormatter: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change"]);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const buttonRef = ref(null);
|
||||
const menuRef = ref(null);
|
||||
const selectAllRef = ref(null);
|
||||
const optionRefs = ref([]);
|
||||
const isOpen = ref(false);
|
||||
const menuStyle = ref({});
|
||||
|
||||
// -1 表示"全选"按钮,0..n-1 表示选项,共同组成一个可循环的键盘焦点环
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
const normalizedOptions = computed(() => props.options.map((option) => {
|
||||
if (typeof option === "string") {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
return {
|
||||
label: option?.label ?? option?.value ?? "",
|
||||
value: option?.value ?? "",
|
||||
icon: option?.icon ?? "",
|
||||
};
|
||||
}));
|
||||
|
||||
const selectedValues = computed(() => new Set(props.modelValue ?? []));
|
||||
const allSelected = computed(() =>
|
||||
normalizedOptions.value.length > 0
|
||||
&& normalizedOptions.value.every((option) => selectedValues.value.has(option.value)),
|
||||
);
|
||||
const summaryLabel = computed(() => {
|
||||
const count = selectedValues.value.size;
|
||||
if (count === 0) {
|
||||
return props.placeholder;
|
||||
}
|
||||
if (props.summaryFormatter) {
|
||||
return props.summaryFormatter(count, normalizedOptions.value.length);
|
||||
}
|
||||
return `已选择 ${count} 项`;
|
||||
});
|
||||
|
||||
function emitSelection(values) {
|
||||
emit("update:modelValue", values);
|
||||
emit("change", values);
|
||||
}
|
||||
|
||||
function toggleOption(option) {
|
||||
const next = normalizedOptions.value
|
||||
.filter((item) => (item.value === option.value
|
||||
? !selectedValues.value.has(item.value)
|
||||
: selectedValues.value.has(item.value)))
|
||||
.map((item) => item.value);
|
||||
emitSelection(next);
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
emitSelection([]);
|
||||
return;
|
||||
}
|
||||
emitSelection(normalizedOptions.value.map((option) => option.value));
|
||||
}
|
||||
|
||||
function setOptionRef(el, index) {
|
||||
if (el) {
|
||||
optionRefs.value[index] = el;
|
||||
return;
|
||||
}
|
||||
delete optionRefs.value[index];
|
||||
}
|
||||
|
||||
function focusActiveOption() {
|
||||
nextTick(() => {
|
||||
if (activeIndex.value < 0) {
|
||||
selectAllRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
optionRefs.value[activeIndex.value]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function moveActiveIndex(step) {
|
||||
if (!isOpen.value) {
|
||||
openMenu();
|
||||
return;
|
||||
}
|
||||
const total = normalizedOptions.value.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
// 焦点环长度为 total + 1(含全选),内部用 0..total 表示,再映射回 -1..total-1
|
||||
const ringSize = total + 1;
|
||||
const current = activeIndex.value + 1;
|
||||
activeIndex.value = ((current + step + ringSize) % ringSize) - 1;
|
||||
focusActiveOption();
|
||||
}
|
||||
|
||||
function openMenu() {
|
||||
if (props.disabled || isOpen.value) {
|
||||
return;
|
||||
}
|
||||
isOpen.value = true;
|
||||
const firstSelected = normalizedOptions.value.findIndex((option) => selectedValues.value.has(option.value));
|
||||
activeIndex.value = firstSelected;
|
||||
nextTick(() => {
|
||||
updatePosition();
|
||||
focusActiveOption();
|
||||
});
|
||||
}
|
||||
|
||||
function closeMenu({ restoreFocus = false } = {}) {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
isOpen.value = false;
|
||||
activeIndex.value = -1;
|
||||
optionRefs.value = [];
|
||||
menuStyle.value = {};
|
||||
if (restoreFocus) {
|
||||
nextTick(() => buttonRef.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (isOpen.value) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
openMenu();
|
||||
}
|
||||
|
||||
function handleButtonKeydown(event) {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
break;
|
||||
case "Escape":
|
||||
if (isOpen.value) {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionKeydown(event, option, index) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
activeIndex.value = index;
|
||||
moveActiveIndex(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
activeIndex.value = index;
|
||||
moveActiveIndex(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (option) {
|
||||
toggleOption(option);
|
||||
break;
|
||||
}
|
||||
toggleSelectAll();
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeMenu({ restoreFocus: true });
|
||||
break;
|
||||
case "Tab":
|
||||
closeMenu();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!buttonRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
computePosition(buttonRef.value, menuRef.value, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
offset(6),
|
||||
flip({ padding: 12 }),
|
||||
shift({ padding: 12 }),
|
||||
size({
|
||||
apply({ rects, elements, availableHeight }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
maxHeight: `${Math.max(availableHeight, 200)}px`,
|
||||
});
|
||||
},
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
menuStyle.value = {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
watchPostEffect((cleanup) => {
|
||||
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
|
||||
cleanup(() => {
|
||||
stopAutoUpdate();
|
||||
});
|
||||
});
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return;
|
||||
}
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
|
||||
watch(() => props.disabled, (disabled) => {
|
||||
if (disabled) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="relative">
|
||||
<button
|
||||
ref="buttonRef"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="flex h-9 w-full items-center justify-between gap-2 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
aria-haspopup="listbox"
|
||||
@click="toggleMenu"
|
||||
@keydown="handleButtonKeydown"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2" :class="selectedValues.size ? 'text-[#e5e5e5]' : 'text-[#7b7b7b]'">
|
||||
<span class="truncate">{{ summaryLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none center-row text-[#8f8f8f] transition-transform duration-200"
|
||||
:class="isOpen ? 'rotate-180' : ''"
|
||||
>
|
||||
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
class="fixed z-[999] flex flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button
|
||||
ref="selectAllRef"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm text-[#d4d4d4] outline-none transition-colors hover:bg-[#303030]"
|
||||
:class="activeIndex === -1 ? 'bg-[#303030]' : ''"
|
||||
@click="toggleSelectAll"
|
||||
@mouseenter="activeIndex = -1"
|
||||
@keydown="handleOptionKeydown($event, null, -1)"
|
||||
>
|
||||
<span :class="[allSelected ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]', 'text-[16px] shrink-0']"></span>
|
||||
<span class="truncate">{{ allSelected ? "取消全选" : "全选" }}</span>
|
||||
</button>
|
||||
|
||||
<ul role="listbox" aria-multiselectable="true" class="overflow-y-auto py-1">
|
||||
<li v-for="(option, index) in normalizedOptions" :key="option.value">
|
||||
<button
|
||||
:ref="(el) => setOptionRef(el, index)"
|
||||
type="button"
|
||||
role="option"
|
||||
class="flex w-full items-center gap-2 rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
|
||||
:class="[
|
||||
selectedValues.has(option.value)
|
||||
? 'bg-[#10AD5D]/15 text-[#10d06f]'
|
||||
: 'text-[#e5e5e5] hover:bg-[#303030]',
|
||||
activeIndex === index ? 'bg-[#303030]' : '',
|
||||
]"
|
||||
:aria-selected="selectedValues.has(option.value)"
|
||||
tabindex="0"
|
||||
@click="toggleOption(option)"
|
||||
@mouseenter="activeIndex = index"
|
||||
@keydown="handleOptionKeydown($event, option, index)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selectedValues.has(option.value) ? 'icon-[mdi--checkbox-marked]' : 'icon-[mdi--checkbox-blank-outline]',
|
||||
'text-[16px] shrink-0',
|
||||
]"
|
||||
></span>
|
||||
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
|
||||
"185aebe19c77425d": "{0} must be a JSON object",
|
||||
"18b7312022cd1840": "Start Service",
|
||||
"1afed6a81a2512d2": "Select model",
|
||||
"1baddde657dd2720": "Current outbound requests use system proxy",
|
||||
"1bc77f5ab979f4c1": "Add Model Settings",
|
||||
"1c631615c1d85c9e": "Log in to Cursor",
|
||||
@@ -29,6 +30,7 @@
|
||||
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer",
|
||||
"28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
|
||||
"2a24519398684ed5": "Visit Homepage",
|
||||
"2c18d5e2d70b45db": "Model prefix",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d706f7981b45a7b": "Local settings saved",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
@@ -44,8 +46,10 @@
|
||||
"37d23612f78a2e63": "Restart Now to Update",
|
||||
"392d0dceb45998d3": "Extreme",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
"3a5040b68abf75f9": "Select all",
|
||||
"3ab8cc15939f3b5c": "Log out",
|
||||
"3af7e5489e61ea51": "Refreshing",
|
||||
"3b2e5f2fba1bcbc7": "Enter the API address and access key",
|
||||
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
|
||||
"3d13868593ae4eeb": "Interface Language",
|
||||
"3d52574ce1500561": "Not connected",
|
||||
@@ -69,6 +73,7 @@
|
||||
"58c6b0935a7216da": "Failed to open contributor profile",
|
||||
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
|
||||
"59a2195a01a8b35b": "{0} must be a valid JSON object",
|
||||
"5a5c8318ef649672": "{0} selected",
|
||||
"5aa8f5590c940829": "Non-cache Input: {0}",
|
||||
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
|
||||
"5d1687a4a41883fd": "Stopping...",
|
||||
@@ -95,8 +100,10 @@
|
||||
"737225e2904673fc": "Estimated output tokens: {0}",
|
||||
"7520bd50a5ee5471": "Stop testing {0}/{1}",
|
||||
"753d8bb0da9913ce": "Duplication failed",
|
||||
"75b5f4c68c79322c": "Select at least one model to save",
|
||||
"774d6e1b7cb89751": "Default Definition",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"7923d007483ae04c": "No models to save",
|
||||
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
|
||||
"7b6187c41e88b70c": "Testing...",
|
||||
"7bf8e2c07e084d09": "Model Editor",
|
||||
@@ -106,15 +113,16 @@
|
||||
"80296f4aa3f4543b": "Cache Read/Write",
|
||||
"81123c56d5d880d0": "API Key",
|
||||
"8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.",
|
||||
"826c0e5a4407befa": "You can select multiple models. Only selected models will be saved, with one configuration generated for each model.",
|
||||
"83be9cac28873059": "Cursor Control Plane Account",
|
||||
"8672864e90417138": "Max",
|
||||
"86df7ec743047234": "Service running",
|
||||
"891bfee3bbe52d3c": "Enter a model ID",
|
||||
"899add6275682210": "Uses 200000 by default when left blank",
|
||||
"8a4ef3e48e4e8a5a": "Enabled",
|
||||
"8c1935935600e336": "Model Test",
|
||||
"8cbcf741e727dbf7": "Model Settings",
|
||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f8baf5d18dd0492": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority; priority can be used for high-priority/Fast scenarios.",
|
||||
"8faa670b512b6b9b": "Open Model Settings",
|
||||
@@ -160,6 +168,7 @@
|
||||
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
||||
"b765005f69fa971f": "e.g. gpt-4.1",
|
||||
"b76a22622020f849": "Select interface protocol endpoint. When selecting 'Custom Path', please enter the complete request URL in the API address bar (including the /chat/completions or /responses suffix). The system will automatically detect the protocol type based on the trailing segment.",
|
||||
"b7ceef2fbfeb4a85": "e.g. GPT-5",
|
||||
"b870928f8f9a24c4": "API Endpoint",
|
||||
"b90a8ac9c488ce46": "Select language",
|
||||
"ba3c66f90fd11725": "System proxy identified",
|
||||
@@ -173,6 +182,7 @@
|
||||
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?",
|
||||
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
|
||||
"c69f5bce63b9f14c": "Settings Folder",
|
||||
"c72d5dc20cd27118": "{0} / {1} models selected",
|
||||
"c8a52b66651d294c": "Failed to log out",
|
||||
"c8c14507b2d37395": "Reasoning Effort",
|
||||
"c98e118e0a43f078": "Model",
|
||||
@@ -206,6 +216,7 @@
|
||||
"e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.",
|
||||
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
|
||||
"e552c2accdbf5178": "Add Model",
|
||||
"e6943d5cbfb863e0": "Fetching models...",
|
||||
"e6faccfddce722e8": "Cache read tokens: {0}",
|
||||
"e8a0a6053998ebfa": "Logged in",
|
||||
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
|
||||
@@ -218,6 +229,7 @@
|
||||
"f3a76d896853c1df": "Miss",
|
||||
"f3fae6cccb9004b1": "Custom header name cannot be empty",
|
||||
"f474a4108aba4c4c": "Stop Service",
|
||||
"f4d4bae588c4c0ff": "Deselect all",
|
||||
"f4f0ead1116b5b62": "Enable",
|
||||
"f56c6c82203b33f6": "Notice",
|
||||
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
|
||||
"18b7312022cd1840": "サービスを開始",
|
||||
"1afed6a81a2512d2": "モデルを選択",
|
||||
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
|
||||
"1bc77f5ab979f4c1": "モデル設定を追加",
|
||||
"1c631615c1d85c9e": "Cursor にログイン",
|
||||
@@ -29,6 +30,7 @@
|
||||
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
||||
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
||||
"2a24519398684ed5": "ホームページへ",
|
||||
"2c18d5e2d70b45db": "モデルのプレフィックス",
|
||||
"2cd0f3be8738a86c": "キャンセル",
|
||||
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
||||
"2f9daa828907b93f": "削除",
|
||||
@@ -44,8 +46,10 @@
|
||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||
"392d0dceb45998d3": "最高",
|
||||
"393df9bb13ea4900": "ヒット",
|
||||
"3a5040b68abf75f9": "すべて選択",
|
||||
"3ab8cc15939f3b5c": "ログアウト",
|
||||
"3af7e5489e61ea51": "更新中",
|
||||
"3b2e5f2fba1bcbc7": "インターフェースのアドレスとアクセスキーを入力してください",
|
||||
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
||||
"3d13868593ae4eeb": "表示言語",
|
||||
"3d52574ce1500561": "未接続",
|
||||
@@ -69,6 +73,7 @@
|
||||
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
|
||||
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
|
||||
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
|
||||
"5a5c8318ef649672": "{0}件を選択中",
|
||||
"5aa8f5590c940829": "非キャッシュ入力:{0}",
|
||||
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
@@ -95,9 +100,11 @@
|
||||
"737225e2904673fc": "推定出力 Token: {0}",
|
||||
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
|
||||
"753d8bb0da9913ce": "複製に失敗しました",
|
||||
"75b5f4c68c79322c": "保存するモデルを1つ以上選択してください",
|
||||
"774d6e1b7cb89751": "デフォルト定義",
|
||||
"77c9e582e85583af": "テスト失敗",
|
||||
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
|
||||
"7923d007483ae04c": "保存できるモデルがありません",
|
||||
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
|
||||
"7b6187c41e88b70c": "テスト中...",
|
||||
"7bf8e2c07e084d09": "モデル編集",
|
||||
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
|
||||
@@ -106,15 +113,16 @@
|
||||
"80296f4aa3f4543b": "キャッシュ読み書き",
|
||||
"81123c56d5d880d0": "API キー",
|
||||
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
|
||||
"826c0e5a4407befa": "複数のモデルを選択できます。保存されるのは選択したモデルだけで、モデルごとに1つの設定が作成されます。",
|
||||
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
|
||||
"8672864e90417138": "最大",
|
||||
"86df7ec743047234": "サービス稼働中",
|
||||
"891bfee3bbe52d3c": "モデルIDを入力してください",
|
||||
"899add6275682210": "空欄で 200000",
|
||||
"8a4ef3e48e4e8a5a": "有効",
|
||||
"8c1935935600e336": "モデルテスト",
|
||||
"8cbcf741e727dbf7": "モデル設定",
|
||||
"8d1de152be6360ce": "有効率: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "コピーしました",
|
||||
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
||||
"8faa670b512b6b9b": "モデル設定を開く",
|
||||
@@ -122,8 +130,8 @@
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "すべてテスト",
|
||||
"91cba5c107a51892": "/ 異常",
|
||||
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
|
||||
"93e08803675e378b": "モデル ID",
|
||||
"92059fe6cd713db4": "サーバーに実際に送信されるモデル名。例: gpt-4.1、claude-sonnet。",
|
||||
"93e08803675e378b": "モデルID",
|
||||
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
|
||||
"942ff2d88baca0c6": "アップデートを確認中...",
|
||||
"970388573a3c88c9": "キャッシュ読み取り:{0} × ${1}/1M = {2}",
|
||||
@@ -160,6 +168,7 @@
|
||||
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
||||
"b765005f69fa971f": "例: gpt-4.1",
|
||||
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL(/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
|
||||
"b7ceef2fbfeb4a85": "例: GPT-5",
|
||||
"b870928f8f9a24c4": "APIエンドポイント",
|
||||
"b90a8ac9c488ce46": "言語を選択",
|
||||
"ba3c66f90fd11725": "システムプロキシを認識しました",
|
||||
@@ -173,6 +182,7 @@
|
||||
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
|
||||
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
||||
"c69f5bce63b9f14c": "設定フォルダー",
|
||||
"c72d5dc20cd27118": "{0} / {1} モデルを選択中",
|
||||
"c8a52b66651d294c": "ログアウトに失敗しました",
|
||||
"c8c14507b2d37395": "推論強度",
|
||||
"c98e118e0a43f078": "モデル",
|
||||
@@ -206,6 +216,7 @@
|
||||
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
|
||||
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
|
||||
"e552c2accdbf5178": "モデルを追加",
|
||||
"e6943d5cbfb863e0": "モデルを取得中...",
|
||||
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
|
||||
"e8a0a6053998ebfa": "ログイン済み",
|
||||
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
|
||||
@@ -218,6 +229,7 @@
|
||||
"f3a76d896853c1df": "ミス",
|
||||
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
|
||||
"f474a4108aba4c4c": "サービスを停止",
|
||||
"f4d4bae588c4c0ff": "すべて選択解除",
|
||||
"f4f0ead1116b5b62": "有効化",
|
||||
"f56c6c82203b33f6": "お知らせ",
|
||||
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
|
||||
"185aebe19c77425d": "{0} должен быть объектом JSON",
|
||||
"18b7312022cd1840": "Запустить сервис",
|
||||
"1afed6a81a2512d2": "Выберите модель",
|
||||
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
|
||||
"1bc77f5ab979f4c1": "Добавить настройки модели",
|
||||
"1c631615c1d85c9e": "Войти в Cursor",
|
||||
@@ -29,6 +30,7 @@
|
||||
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
|
||||
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
|
||||
"2a24519398684ed5": "Перейти на домашнюю страницу",
|
||||
"2c18d5e2d70b45db": "Префикс модели",
|
||||
"2cd0f3be8738a86c": "Отмена",
|
||||
"2d706f7981b45a7b": "Локальные настройки сохранены",
|
||||
"2f9daa828907b93f": "Удалить",
|
||||
@@ -44,8 +46,10 @@
|
||||
"37d23612f78a2e63": "Перезапустить и обновить",
|
||||
"392d0dceb45998d3": "Очень высокая",
|
||||
"393df9bb13ea4900": "Попадание",
|
||||
"3a5040b68abf75f9": "Выбрать все",
|
||||
"3ab8cc15939f3b5c": "Выйти",
|
||||
"3af7e5489e61ea51": "Обновление",
|
||||
"3b2e5f2fba1bcbc7": "Введите адрес интерфейса и ключ доступа",
|
||||
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
|
||||
"3d13868593ae4eeb": "Язык интерфейса",
|
||||
"3d52574ce1500561": "Не подключено",
|
||||
@@ -69,6 +73,7 @@
|
||||
"58c6b0935a7216da": "Не удалось открыть профиль участника",
|
||||
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
|
||||
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
|
||||
"5a5c8318ef649672": "Выбрано: {0}",
|
||||
"5aa8f5590c940829": "Ввод без кеша: {0}",
|
||||
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
|
||||
"5d1687a4a41883fd": "Остановка...",
|
||||
@@ -95,9 +100,11 @@
|
||||
"737225e2904673fc": "Расчетные выходные токены: {0}",
|
||||
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
|
||||
"753d8bb0da9913ce": "Не удалось дублировать",
|
||||
"75b5f4c68c79322c": "Выберите хотя бы одну модель для сохранения",
|
||||
"774d6e1b7cb89751": "Стандартный расчет",
|
||||
"77c9e582e85583af": "Проверка не пройдена",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе и помогает различать модели.",
|
||||
"7923d007483ae04c": "Нет моделей для сохранения",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
|
||||
"7b6187c41e88b70c": "Проверка...",
|
||||
"7bf8e2c07e084d09": "Редактор модели",
|
||||
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
||||
@@ -106,15 +113,16 @@
|
||||
"80296f4aa3f4543b": "Чтение/запись кеша",
|
||||
"81123c56d5d880d0": "Ключ API",
|
||||
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
|
||||
"826c0e5a4407befa": "Можно выбрать несколько моделей. Будут сохранены только выбранные модели, для каждой будет создана отдельная конфигурация.",
|
||||
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
|
||||
"8672864e90417138": "Максимальная",
|
||||
"86df7ec743047234": "Сервис запущен",
|
||||
"891bfee3bbe52d3c": "Введите идентификатор модели",
|
||||
"899add6275682210": "Если оставить пустым, используется 200000",
|
||||
"8a4ef3e48e4e8a5a": "Включено",
|
||||
"8c1935935600e336": "Проверка модели",
|
||||
"8cbcf741e727dbf7": "Настройки модели",
|
||||
"8d1de152be6360ce": "Доля успешных: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "например, OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Скопировано",
|
||||
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
||||
"8faa670b512b6b9b": "Открыть настройки модели",
|
||||
@@ -122,7 +130,7 @@
|
||||
"917b1c1f18d0276b": "Сохранение...",
|
||||
"9196835e388d2550": "Проверить все",
|
||||
"91cba5c107a51892": "/ Ошибочные",
|
||||
"92059fe6cd713db4": "Имя модели, которое фактически отправляется серверу, например gpt-4.1 или claude-sonnet.",
|
||||
"92059fe6cd713db4": "Имя модели, фактически отправляемое серверу, например gpt-4.1 или claude-sonnet.",
|
||||
"93e08803675e378b": "Идентификатор модели",
|
||||
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
|
||||
"942ff2d88baca0c6": "Проверка обновлений...",
|
||||
@@ -160,6 +168,7 @@
|
||||
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
||||
"b765005f69fa971f": "например, gpt-4.1",
|
||||
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
|
||||
"b7ceef2fbfeb4a85": "например, GPT-5",
|
||||
"b870928f8f9a24c4": "Конечная точка API",
|
||||
"b90a8ac9c488ce46": "Выберите язык",
|
||||
"ba3c66f90fd11725": "Обнаружен системный прокси",
|
||||
@@ -173,6 +182,7 @@
|
||||
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
|
||||
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
||||
"c69f5bce63b9f14c": "Папка настроек",
|
||||
"c72d5dc20cd27118": "Выбрано моделей: {0} / {1}",
|
||||
"c8a52b66651d294c": "Не удалось выйти",
|
||||
"c8c14507b2d37395": "Интенсивность рассуждений",
|
||||
"c98e118e0a43f078": "Модель",
|
||||
@@ -206,6 +216,7 @@
|
||||
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
|
||||
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
|
||||
"e552c2accdbf5178": "Добавить модель",
|
||||
"e6943d5cbfb863e0": "Получение моделей...",
|
||||
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
|
||||
"e8a0a6053998ebfa": "Выполнен вход",
|
||||
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
|
||||
@@ -218,6 +229,7 @@
|
||||
"f3a76d896853c1df": "Промах",
|
||||
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
||||
"f474a4108aba4c4c": "Остановить сервис",
|
||||
"f4d4bae588c4c0ff": "Снять выделение со всех",
|
||||
"f4f0ead1116b5b62": "Включить",
|
||||
"f56c6c82203b33f6": "Уведомление",
|
||||
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
"185aebe19c77425d": "{0}必须是 JSON 对象",
|
||||
"18b7312022cd1840": "启动服务",
|
||||
"1afed6a81a2512d2": "选择模型",
|
||||
"1baddde657dd2720": "当前出站请求使用系统代理",
|
||||
"1bc77f5ab979f4c1": "新增模型配置",
|
||||
"1c631615c1d85c9e": "登录 Cursor",
|
||||
@@ -29,6 +30,7 @@
|
||||
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
||||
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
||||
"2a24519398684ed5": "访问主页",
|
||||
"2c18d5e2d70b45db": "模型前缀",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d706f7981b45a7b": "本地配置已保存",
|
||||
"2f9daa828907b93f": "删除",
|
||||
@@ -44,8 +46,10 @@
|
||||
"37d23612f78a2e63": "立即重启更新",
|
||||
"392d0dceb45998d3": "极高",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"3a5040b68abf75f9": "全选",
|
||||
"3ab8cc15939f3b5c": "退出登录",
|
||||
"3af7e5489e61ea51": "刷新中",
|
||||
"3b2e5f2fba1bcbc7": "请输入接口地址和访问密钥",
|
||||
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3d52574ce1500561": "未连接",
|
||||
@@ -69,6 +73,7 @@
|
||||
"58c6b0935a7216da": "打开贡献者主页失败",
|
||||
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
|
||||
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
|
||||
"5a5c8318ef649672": "已选择 {0} 项",
|
||||
"5aa8f5590c940829": "非缓存输入:{0}",
|
||||
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
@@ -95,8 +100,10 @@
|
||||
"737225e2904673fc": "输出推算:{0}",
|
||||
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
||||
"753d8bb0da9913ce": "复制失败",
|
||||
"75b5f4c68c79322c": "请先选择要保存的模型",
|
||||
"774d6e1b7cb89751": "默认口径",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"7923d007483ae04c": "没有可保存的模型",
|
||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||
"7b6187c41e88b70c": "测试中...",
|
||||
"7bf8e2c07e084d09": "模型编辑",
|
||||
@@ -106,15 +113,16 @@
|
||||
"80296f4aa3f4543b": "缓存读写",
|
||||
"81123c56d5d880d0": "访问密钥",
|
||||
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
||||
"826c0e5a4407befa": "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
|
||||
"83be9cac28873059": "Cursor 控制面账号",
|
||||
"8672864e90417138": "最高",
|
||||
"86df7ec743047234": "服务运行中",
|
||||
"891bfee3bbe52d3c": "请填写模型标识",
|
||||
"899add6275682210": "留空时默认 200000",
|
||||
"8a4ef3e48e4e8a5a": "已开启",
|
||||
"8c1935935600e336": "模型测试",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8d1de152be6360ce": "有效占比:{0}",
|
||||
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
"8faa670b512b6b9b": "打开模型配置",
|
||||
@@ -160,6 +168,7 @@
|
||||
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
||||
"b765005f69fa971f": "例如:gpt-4.1",
|
||||
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
||||
"b7ceef2fbfeb4a85": "例如:GPT-5",
|
||||
"b870928f8f9a24c4": "接口端点",
|
||||
"b90a8ac9c488ce46": "选择语言",
|
||||
"ba3c66f90fd11725": "已识别系统代理",
|
||||
@@ -173,6 +182,7 @@
|
||||
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
||||
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
||||
"c69f5bce63b9f14c": "设置文件夹",
|
||||
"c72d5dc20cd27118": "已选择 {0} / {1} 个模型",
|
||||
"c8a52b66651d294c": "退出登录失败",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c98e118e0a43f078": "模型",
|
||||
@@ -206,6 +216,7 @@
|
||||
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
|
||||
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
|
||||
"e552c2accdbf5178": "新增模型",
|
||||
"e6943d5cbfb863e0": "正在获取模型...",
|
||||
"e6faccfddce722e8": "缓存读取:{0}",
|
||||
"e8a0a6053998ebfa": "已经登录",
|
||||
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
|
||||
@@ -218,6 +229,7 @@
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
|
||||
"f474a4108aba4c4c": "关闭服务",
|
||||
"f4d4bae588c4c0ff": "取消全选",
|
||||
"f4f0ead1116b5b62": "启用",
|
||||
"f56c6c82203b33f6": "提示",
|
||||
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
|
||||
|
||||
@@ -161,3 +161,9 @@ export function getModelAdapterTestResults() {
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchModelAdapterModels(payload) {
|
||||
return withApiLogging("FetchModelAdapterModels", payload, () =>
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.FetchModelAdapterModels`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
startProxyService,
|
||||
stopProxyService,
|
||||
testModelAdapter,
|
||||
fetchModelAdapterModels,
|
||||
} from "@/services/clientApi";
|
||||
|
||||
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
||||
@@ -1143,6 +1144,100 @@ export async function saveModelAdapterAt(index, adapter) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAvailableModelIDs(payload) {
|
||||
const result = await fetchModelAdapterModels(payload);
|
||||
return asArray(result?.models)
|
||||
.map((item) => asString(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPrefixedModelDisplayName(prefix, modelID) {
|
||||
const normalizedPrefix = asString(prefix) || "模型";
|
||||
return `${normalizedPrefix}-${asString(modelID)}`;
|
||||
}
|
||||
|
||||
export function buildModelAdaptersFromModelIDs(source, modelIDs, prefix) {
|
||||
const base = normalizeModelAdapter(source);
|
||||
const seen = new Set();
|
||||
return asArray(modelIDs)
|
||||
.map((item) => asString(item))
|
||||
.filter((modelID) => {
|
||||
if (!modelID || seen.has(modelID)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(modelID);
|
||||
return true;
|
||||
})
|
||||
.map((modelID) => normalizeModelAdapter({
|
||||
...base,
|
||||
id: "",
|
||||
modelID,
|
||||
displayName: buildPrefixedModelDisplayName(prefix, modelID),
|
||||
tooltipData: base.tooltipData || "备注",
|
||||
}));
|
||||
}
|
||||
|
||||
function findModelAdapterUpsertIndex(adapters, target) {
|
||||
return adapters.findIndex((adapter) => {
|
||||
const current = normalizeModelAdapter(adapter);
|
||||
return current.type === target.type
|
||||
&& normalizeBaseURL(current.baseURL) === normalizeBaseURL(target.baseURL)
|
||||
&& current.apiKey === target.apiKey
|
||||
&& current.modelID === target.modelID
|
||||
&& current.displayName === target.displayName
|
||||
&& (current.type !== "openai" || current.openAIEndpoint === target.openAIEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveModelAdaptersFromModelIDs(source, modelIDs, prefix, selectedModelID = "") {
|
||||
const generatedAdapters = buildModelAdaptersFromModelIDs(source, modelIDs, prefix);
|
||||
if (generatedAdapters.length === 0) {
|
||||
return { ok: false, error: "没有可保存的模型" };
|
||||
}
|
||||
|
||||
const generatedError = validateModelAdapters(generatedAdapters);
|
||||
if (generatedError) {
|
||||
return { ok: false, error: generatedError };
|
||||
}
|
||||
|
||||
const currentConfig = await loadPersistedUserConfig();
|
||||
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
||||
const targetModelID = asString(selectedModelID) || generatedAdapters[0]?.modelID || "";
|
||||
let selectedIndex = -1;
|
||||
|
||||
for (const adapter of generatedAdapters) {
|
||||
const index = findModelAdapterUpsertIndex(nextAdapters, adapter);
|
||||
if (index >= 0) {
|
||||
nextAdapters.splice(index, 1, adapter);
|
||||
if (selectedIndex < 0 && adapter.modelID === targetModelID) {
|
||||
selectedIndex = index;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
nextAdapters.push(adapter);
|
||||
if (selectedIndex < 0 && adapter.modelID === targetModelID) {
|
||||
selectedIndex = nextAdapters.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await persistConfigPayload(
|
||||
{
|
||||
...currentConfig,
|
||||
modelAdapters: nextAdapters,
|
||||
},
|
||||
{ modelAdaptersOnly: true },
|
||||
);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
index: selectedIndex,
|
||||
adapter: selectedIndex >= 0 ? appState.modelAdapters[selectedIndex] ?? null : null,
|
||||
count: generatedAdapters.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteModelAdapterAt(index) {
|
||||
const currentConfig = await loadPersistedUserConfig();
|
||||
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Input from "@/components/ui/Input.vue";
|
||||
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
|
||||
import MultiSelect from "@/components/ui/MultiSelect.vue";
|
||||
import Select from "@/components/ui/Select.vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import { getModelEditorContext } from "@/services/clientApi";
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
ANTHROPIC_THINKING_EFFORT_DEFAULT,
|
||||
appState,
|
||||
buildModelAdapterTestRequestHash,
|
||||
buildModelAdaptersFromModelIDs,
|
||||
createEmptyModelAdapter,
|
||||
CUSTOM_HEADERS_DEFAULT_JSON,
|
||||
EXTRA_PARAMS_DEFAULT_JSON,
|
||||
fetchAvailableModelIDs,
|
||||
getModelAdapterTestResult,
|
||||
getModelAdapterTestResultByID,
|
||||
isModelAdapterTestResultStale,
|
||||
@@ -22,11 +25,12 @@ import {
|
||||
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
runModelAdapterTest,
|
||||
saveModelAdapterAt,
|
||||
saveModelAdaptersFromModelIDs,
|
||||
toUserError,
|
||||
validateModelAdapters,
|
||||
} from "@/state/appState";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
const modelTypeTabs = [
|
||||
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
|
||||
@@ -61,6 +65,14 @@ const errorMessage = ref("");
|
||||
const loading = ref(true);
|
||||
const lastTestAdapterID = ref("");
|
||||
const localTestFailure = ref("");
|
||||
const modelPrefix = ref("");
|
||||
const existingModelPrefixCleared = ref(false);
|
||||
const availableModelIDs = ref([]);
|
||||
const selectedModelIDs = ref([]);
|
||||
const modelSelectionMode = ref("auto");
|
||||
const modelListLoading = ref(false);
|
||||
const modelListRequestSeq = ref(0);
|
||||
let modelListDebounceTimer = 0;
|
||||
|
||||
function createOptionalPositiveIntegerModel(key) {
|
||||
return computed({
|
||||
@@ -80,14 +92,44 @@ const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWind
|
||||
const interfacePlaceholder = computed(() =>
|
||||
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
|
||||
);
|
||||
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
|
||||
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
|
||||
const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
|
||||
label: modelID,
|
||||
value: modelID,
|
||||
icon: "icon-[mdi--cube-outline]",
|
||||
})));
|
||||
const isManualModelInput = computed(() => modelSelectionMode.value === "manual");
|
||||
const activeModelIDs = computed(() => (
|
||||
isManualModelInput.value
|
||||
? [String(draft.modelID || "").trim()].filter(Boolean)
|
||||
: selectedModelIDs.value
|
||||
));
|
||||
const primaryModelID = computed(() => (
|
||||
isManualModelInput.value
|
||||
? String(draft.modelID || "").trim()
|
||||
: selectedModelIDs.value.includes(draft.modelID) ? draft.modelID : selectedModelIDs.value[0] || ""
|
||||
));
|
||||
const selectedTestAdapter = computed(() => {
|
||||
if (isManualModelInput.value) {
|
||||
const modelID = primaryModelID.value;
|
||||
return normalizeModelAdapter({
|
||||
...draft,
|
||||
modelID,
|
||||
displayName: modelID,
|
||||
});
|
||||
}
|
||||
const adapters = buildModelAdaptersFromModelIDs(draft, selectedModelIDs.value, modelPrefix.value);
|
||||
return adapters.find((adapter) => adapter.modelID === primaryModelID.value)
|
||||
?? adapters[0]
|
||||
?? normalizeModelAdapter(draft);
|
||||
});
|
||||
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
|
||||
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
|
||||
const rememberedModelTestResult = computed(() =>
|
||||
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
|
||||
);
|
||||
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
|
||||
const modelTestResultStale = computed(() =>
|
||||
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
|
||||
isModelAdapterTestResultStale(selectedTestAdapter.value, activeModelTestResult.value),
|
||||
);
|
||||
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
|
||||
const modelTestSummary = computed(() => {
|
||||
@@ -125,7 +167,8 @@ function ensureAnthropicThinkingEffort() {
|
||||
|
||||
const fieldTips = {
|
||||
displayName: "仅用于界面展示,便于你区分不同模型。",
|
||||
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
modelID: "可多选。保存时只会写入选中的模型,每个模型生成一条配置。",
|
||||
manualModelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
apiKey: "调用该模型服务需要使用的访问密钥。",
|
||||
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
@@ -146,6 +189,11 @@ async function loadContext() {
|
||||
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
|
||||
const parsed = JSON.parse(ctx.adapterJSON || "{}");
|
||||
Object.assign(draft, normalizeModelAdapter(parsed));
|
||||
if (draft.modelID) {
|
||||
availableModelIDs.value = [draft.modelID];
|
||||
selectedModelIDs.value = [draft.modelID];
|
||||
}
|
||||
modelPrefix.value = draft.displayName || "";
|
||||
if (!draft.type) {
|
||||
draft.type = "openai";
|
||||
}
|
||||
@@ -157,8 +205,81 @@ async function loadContext() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectionWithAvailable() {
|
||||
const available = availableModelIDs.value;
|
||||
const kept = selectedModelIDs.value.filter((modelID) => available.includes(modelID));
|
||||
selectedModelIDs.value = kept;
|
||||
draft.modelID = kept.includes(draft.modelID) ? draft.modelID : kept[0] || "";
|
||||
}
|
||||
|
||||
function handleModelSelectionChange(values) {
|
||||
selectedModelIDs.value = values;
|
||||
draft.modelID = values.includes(draft.modelID) ? draft.modelID : values[0] || "";
|
||||
}
|
||||
|
||||
async function refreshModelList() {
|
||||
const baseURL = String(draft.baseURL || "").trim();
|
||||
const apiKey = String(draft.apiKey || "").trim();
|
||||
if (!baseURL || !apiKey || !draft.type) {
|
||||
modelSelectionMode.value = "auto";
|
||||
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
|
||||
syncSelectionWithAvailable();
|
||||
return [];
|
||||
}
|
||||
|
||||
const requestSeq = modelListRequestSeq.value + 1;
|
||||
modelListRequestSeq.value = requestSeq;
|
||||
modelSelectionMode.value = "auto";
|
||||
modelListLoading.value = true;
|
||||
try {
|
||||
const models = await fetchAvailableModelIDs({
|
||||
type: draft.type,
|
||||
baseURL,
|
||||
apiKey,
|
||||
customHeadersEnabled: draft.customHeadersEnabled,
|
||||
customHeadersJSON: draft.customHeadersJSON,
|
||||
});
|
||||
if (requestSeq !== modelListRequestSeq.value) {
|
||||
return availableModelIDs.value;
|
||||
}
|
||||
if (editorIndex.value >= 0 && !existingModelPrefixCleared.value) {
|
||||
modelPrefix.value = "";
|
||||
existingModelPrefixCleared.value = true;
|
||||
}
|
||||
modelSelectionMode.value = "auto";
|
||||
availableModelIDs.value = models;
|
||||
syncSelectionWithAvailable();
|
||||
return models;
|
||||
} catch (_error) {
|
||||
if (requestSeq === modelListRequestSeq.value) {
|
||||
modelSelectionMode.value = "manual";
|
||||
availableModelIDs.value = [];
|
||||
selectedModelIDs.value = [];
|
||||
}
|
||||
return availableModelIDs.value;
|
||||
} finally {
|
||||
if (requestSeq === modelListRequestSeq.value) {
|
||||
modelListLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDraft() {
|
||||
const adapter = normalizeModelAdapter(draft);
|
||||
const models = activeModelIDs.value;
|
||||
if (models.length === 0) {
|
||||
const error = isManualModelInput.value ? "请填写模型标识" : "请先选择要保存的模型";
|
||||
errorMessage.value = error;
|
||||
return { ok: false, error, adapter: null };
|
||||
}
|
||||
|
||||
const selectedModelID = primaryModelID.value || models[0];
|
||||
const adapter = normalizeModelAdapter({
|
||||
...draft,
|
||||
modelID: selectedModelID,
|
||||
displayName: isManualModelInput.value
|
||||
? String(modelPrefix.value || selectedModelID).trim()
|
||||
: `${String(modelPrefix.value || "模型").trim()}-${selectedModelID}`,
|
||||
});
|
||||
|
||||
const singleCheck = validateModelAdapters([adapter]);
|
||||
if (singleCheck) {
|
||||
@@ -166,7 +287,9 @@ async function persistDraft() {
|
||||
return { ok: false, error: singleCheck, adapter: null };
|
||||
}
|
||||
|
||||
const result = await saveModelAdapterAt(editorIndex.value, adapter);
|
||||
const result = isManualModelInput.value
|
||||
? await saveModelAdapterAt(editorIndex.value, adapter)
|
||||
: await saveModelAdaptersFromModelIDs(adapter, models, modelPrefix.value, selectedModelID);
|
||||
if (!result.ok) {
|
||||
errorMessage.value = result.error;
|
||||
return { ok: false, error: result.error, adapter: null };
|
||||
@@ -177,12 +300,14 @@ async function persistDraft() {
|
||||
}
|
||||
if (result.adapter) {
|
||||
Object.assign(draft, normalizeModelAdapter(result.adapter));
|
||||
} else {
|
||||
Object.assign(draft, adapter);
|
||||
}
|
||||
errorMessage.value = "";
|
||||
return {
|
||||
ok: true,
|
||||
error: "",
|
||||
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
|
||||
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(adapter),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +325,10 @@ async function handleCancel() {
|
||||
|
||||
function handleModelTypeChange(type) {
|
||||
draft.type = type;
|
||||
modelSelectionMode.value = "auto";
|
||||
availableModelIDs.value = [];
|
||||
selectedModelIDs.value = [];
|
||||
draft.modelID = "";
|
||||
if (type === "openai" && !draft.openAIEndpoint) {
|
||||
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
||||
} else if (type === "anthropic") {
|
||||
@@ -273,9 +402,32 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [draft.type, draft.baseURL, draft.apiKey, draft.customHeadersEnabled, draft.customHeadersJSON],
|
||||
() => {
|
||||
window.clearTimeout(modelListDebounceTimer);
|
||||
const baseURL = String(draft.baseURL || "").trim();
|
||||
const apiKey = String(draft.apiKey || "").trim();
|
||||
if (!baseURL || !apiKey) {
|
||||
modelSelectionMode.value = "auto";
|
||||
modelListLoading.value = false;
|
||||
availableModelIDs.value = draft.modelID ? [draft.modelID] : [];
|
||||
syncSelectionWithAvailable();
|
||||
return;
|
||||
}
|
||||
modelListDebounceTimer = window.setTimeout(() => {
|
||||
void refreshModelList();
|
||||
}, 600);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContext();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(modelListDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -318,26 +470,13 @@ onMounted(async () => {
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.displayName" />
|
||||
<span>显示名称</span>
|
||||
<Tooltip :content="fieldTips.baseURL" />
|
||||
<span>接口地址</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.displayName"
|
||||
v-model="draft.baseURL"
|
||||
type="text"
|
||||
placeholder="例如:OpenAI - GPT-4.1"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.modelID" />
|
||||
<span>模型标识</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.modelID"
|
||||
type="text"
|
||||
placeholder="例如:gpt-4.1"
|
||||
:placeholder="interfacePlaceholder"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
@@ -358,17 +497,41 @@ onMounted(async () => {
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.baseURL" />
|
||||
<span>接口地址</span>
|
||||
<Tooltip :content="isManualModelInput ? fieldTips.displayName : fieldTips.modelID" />
|
||||
<span>{{ isManualModelInput ? "显示名称" : "模型前缀" }}</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.baseURL"
|
||||
v-model="modelPrefix"
|
||||
type="text"
|
||||
:placeholder="interfacePlaceholder"
|
||||
placeholder="例如:GPT-5"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="isManualModelInput ? fieldTips.manualModelID : fieldTips.modelID" />
|
||||
<span>{{ isManualModelInput ? "模型标识" : "选择模型" }}</span>
|
||||
</span>
|
||||
<input
|
||||
v-if="isManualModelInput"
|
||||
v-model="draft.modelID"
|
||||
type="text"
|
||||
placeholder="例如:gpt-4.1"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-else
|
||||
:model-value="selectedModelIDs"
|
||||
:options="modelOptions"
|
||||
:disabled="modelListLoading || modelOptions.length === 0"
|
||||
:placeholder="modelListLoading ? '正在获取模型...' : '请输入接口地址和访问密钥'"
|
||||
:summary-formatter="(count, total) => `已选择 ${count} / ${total} 个模型`"
|
||||
aria-label="选择模型"
|
||||
@update:model-value="handleModelSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.contextWindowTokens" />
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"encoding/pem"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
goruntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +39,8 @@ const (
|
||||
appName = "Cursor助手"
|
||||
// adRefreshInterval 表示后台广告拉取间隔。
|
||||
adRefreshInterval = 3 * time.Minute
|
||||
// disableWebViewSandboxEnv allows affected VDI users to opt out of the WebView2 sandbox.
|
||||
disableWebViewSandboxEnv = "CURSOR_BYOK_DISABLE_WEBVIEW_SANDBOX"
|
||||
)
|
||||
|
||||
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
|
||||
@@ -134,6 +138,9 @@ func Run(resources EmbeddedResources) error {
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(resources.Assets),
|
||||
},
|
||||
Windows: application.WindowsOptions{
|
||||
AdditionalBrowserArgs: windowsAdditionalBrowserArgs(),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||
@@ -415,6 +422,14 @@ func Run(resources EmbeddedResources) error {
|
||||
return app.Run()
|
||||
}
|
||||
|
||||
func windowsAdditionalBrowserArgs() []string {
|
||||
disableSandbox, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(disableWebViewSandboxEnv)))
|
||||
if err != nil || !disableSandbox {
|
||||
return nil
|
||||
}
|
||||
return []string{"--no-sandbox"}
|
||||
}
|
||||
|
||||
func browserReachableLoopbackBaseURL(listenAddr string) string {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
|
||||
if err != nil || strings.TrimSpace(port) == "" {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWindowsAdditionalBrowserArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset"},
|
||||
{name: "enabled with one", env: "1", want: []string{"--no-sandbox"}},
|
||||
{name: "enabled with true", env: " true ", want: []string{"--no-sandbox"}},
|
||||
{name: "disabled", env: "false"},
|
||||
{name: "invalid", env: "yes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(disableWebViewSandboxEnv, tt.env)
|
||||
if got := windowsAdditionalBrowserArgs(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("windowsAdditionalBrowserArgs() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1903,11 +1903,13 @@ func openAIThinkingDisableKind(baseURL string, modelID string, endpoint string)
|
||||
strings.Contains(base, "zhipu") ||
|
||||
strings.Contains(base, "xiaomimimo") ||
|
||||
strings.Contains(base, "mimo") ||
|
||||
strings.Contains(base, "minimax") ||
|
||||
strings.Contains(model, "deepseek") ||
|
||||
strings.Contains(model, "glm") ||
|
||||
strings.Contains(model, "zai") ||
|
||||
strings.Contains(model, "zhipu") ||
|
||||
strings.Contains(model, "mimo"):
|
||||
strings.Contains(model, "mimo") ||
|
||||
strings.Contains(model, "minimax"):
|
||||
return "thinking_type"
|
||||
case openAIModelSupportsReasoningNone(model):
|
||||
return "reasoning_none"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package modeladapter
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOpenAIThinkingDisableKindMiniMax verifies that the MiniMax OpenAI-compatible
|
||||
// endpoint is routed to the thinking_type disable branch, so that disabling
|
||||
// thinking writes thinking:{type:"disabled"} and drops reasoning_effort. This
|
||||
// covers the global endpoint, the China endpoint and a custom proxy that only
|
||||
// exposes the MiniMax model id, plus regression cases for the other branches.
|
||||
func TestOpenAIThinkingDisableKindMiniMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
modelID string
|
||||
endpoint string
|
||||
want string
|
||||
}{
|
||||
// MiniMax global endpoint
|
||||
{name: "minimax global base + M3", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "minimax global base + M2.7", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// MiniMax China endpoint
|
||||
{name: "minimax cn base + M3", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "minimax cn base + M2.7", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// MiniMax model id only (custom base)
|
||||
{name: "minimax model only (custom base)", baseURL: "https://custom.proxy.example.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// Regression: enable_thinking branch (qwen) is unaffected
|
||||
{name: "qwen via dashscope", baseURL: "https://dashscope.aliyuncs.com/v1", modelID: "qwen-max", endpoint: "/chat/completions", want: "enable_thinking"},
|
||||
// Regression: reasoning_none branch (gpt-5.1+/gpt-6) is unaffected
|
||||
{name: "gpt-6", baseURL: "https://api.openai.com/v1", modelID: "gpt-6", endpoint: "/chat/completions", want: "reasoning_none"},
|
||||
// Regression: unknown provider does not disable
|
||||
{name: "unknown provider", baseURL: "https://api.unknown-llm.com/v1", modelID: "some-model", endpoint: "/chat/completions", want: ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := openAIThinkingDisableKind(tc.baseURL, tc.modelID, tc.endpoint)
|
||||
if got != tc.want {
|
||||
t.Fatalf("openAIThinkingDisableKind(%q, %q, %q) = %q, want %q", tc.baseURL, tc.modelID, tc.endpoint, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiniMax verifies that when ThinkingEffort=disabled
|
||||
// and the provider is MiniMax, applyOpenAIThinkingDisable writes
|
||||
// thinking:{type:"disabled"} and deletes reasoning_effort on both the global and
|
||||
// the China endpoint.
|
||||
func TestApplyOpenAIThinkingDisableMiniMax(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
modelID string
|
||||
}{
|
||||
{name: "global endpoint", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3"},
|
||||
{name: "china endpoint", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "disabled", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{
|
||||
"model": tc.modelID,
|
||||
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
applyOpenAIThinkingDisable(body, req, tc.baseURL, tc.modelID, "/chat/completions")
|
||||
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected body[thinking] to be map[string]any, got %T (%v)", body["thinking"], body["thinking"])
|
||||
}
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("expected thinking.type=disabled, got %v", thinking["type"])
|
||||
}
|
||||
if _, stillPresent := body["reasoning_effort"]; stillPresent {
|
||||
t.Fatalf("reasoning_effort should be deleted when thinking disabled, got %v", body["reasoning_effort"])
|
||||
}
|
||||
if got := req.RequestKnobs["thinking_disabled_provider_param"]; got != "thinking.type" {
|
||||
t.Fatalf("expected request knob thinking_disabled_provider_param=thinking.type, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiniMaxNotTriggered verifies that a non-disabled
|
||||
// thinking effort does not inject the disable field for MiniMax.
|
||||
func TestApplyOpenAIThinkingDisableMiniMaxNotTriggered(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "high", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{"model": "MiniMax-M3", "reasoning_effort": "high"}
|
||||
applyOpenAIThinkingDisable(body, req, "https://api.minimax.io/v1", "MiniMax-M3", "/chat/completions")
|
||||
if _, present := body["thinking"]; present {
|
||||
t.Fatalf("thinking should not be injected when ThinkingEffort != disabled, got %v", body["thinking"])
|
||||
}
|
||||
if body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("reasoning_effort should be preserved when not disabled, got %v", body["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
||||
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
||||
TurnPhaseCompacting TurnPhase = "compacting"
|
||||
TurnPhaseCheckpointing TurnPhase = "checkpointing"
|
||||
TurnPhaseCompleted TurnPhase = "completed"
|
||||
TurnPhaseFailed TurnPhase = "failed"
|
||||
TurnPhaseCanceled TurnPhase = "canceled"
|
||||
@@ -66,6 +67,7 @@ const (
|
||||
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
||||
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
||||
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
||||
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
|
||||
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
||||
)
|
||||
|
||||
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
|
||||
case streamCommandCancel:
|
||||
return service.handleCancelIntent(command.Intent)
|
||||
case streamCommandMetadata:
|
||||
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
|
||||
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
|
||||
}
|
||||
return service.handleMetadataIntent(command.Intent)
|
||||
case streamCommandExecResult:
|
||||
return service.handleExecResult(command.Intent)
|
||||
@@ -1003,6 +1008,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
|
||||
return nil
|
||||
}
|
||||
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
||||
case streamTimerCheckpointBlobs:
|
||||
return service.handleCheckpointBlobTimeout(stream)
|
||||
case streamTimerOrphanCancel:
|
||||
stream.mu.Lock()
|
||||
subscriberCount := len(stream.Subscribers)
|
||||
|
||||
@@ -76,6 +76,12 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
||||
if existing.BackgroundShellActions == nil {
|
||||
existing.BackgroundShellActions = make(map[string]time.Time)
|
||||
}
|
||||
if existing.PendingCheckpointBlobWrites == nil {
|
||||
existing.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
}
|
||||
if existing.ConfirmedCheckpointBlobs == nil {
|
||||
existing.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||
}
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
existing.mu.Unlock()
|
||||
return existing, nil
|
||||
@@ -102,6 +108,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
||||
BackgroundShellsByMessageID: make(map[uint32]string),
|
||||
BackgroundShellsByExecID: make(map[string]string),
|
||||
BackgroundShellActions: make(map[string]time.Time),
|
||||
PendingCheckpointBlobWrites: make(map[uint32]string),
|
||||
ConfirmedCheckpointBlobs: make(map[string]struct{}),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
const checkpointBlobWriteTimeout = 5 * time.Second
|
||||
|
||||
type pendingCheckpointBlobWrite struct {
|
||||
requestID uint32
|
||||
blob CheckpointBlob
|
||||
}
|
||||
|
||||
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
|
||||
if completion == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *completion
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
|
||||
if service == nil || stream == nil || projection == nil || projection.State == nil {
|
||||
return nil
|
||||
}
|
||||
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
|
||||
if !ok || state == nil {
|
||||
return fmt.Errorf("clone checkpoint state")
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
if stream.PendingCheckpointBlobWrites == nil {
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
}
|
||||
if stream.ConfirmedCheckpointBlobs == nil {
|
||||
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||
}
|
||||
if completion == nil && stream.PendingCheckpoint != nil {
|
||||
completion = stream.PendingCheckpoint.Completion
|
||||
}
|
||||
required := make(map[string]struct{}, len(projection.Blobs))
|
||||
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
|
||||
for _, key := range stream.PendingCheckpointBlobWrites {
|
||||
pendingKeys[key] = struct{}{}
|
||||
}
|
||||
toWrite := make([]pendingCheckpointBlobWrite, 0, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
key := string(blob.ID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
required[key] = struct{}{}
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; confirmed {
|
||||
continue
|
||||
}
|
||||
if _, pending := pendingKeys[key]; pending {
|
||||
continue
|
||||
}
|
||||
stream.NextCheckpointBlobRequestID++
|
||||
if stream.NextCheckpointBlobRequestID == 0 {
|
||||
stream.NextCheckpointBlobRequestID++
|
||||
}
|
||||
requestID := stream.NextCheckpointBlobRequestID
|
||||
stream.PendingCheckpointBlobWrites[requestID] = key
|
||||
pendingKeys[key] = struct{}{}
|
||||
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
|
||||
}
|
||||
stream.PendingCheckpoint = &pendingCheckpointPublish{
|
||||
State: state,
|
||||
Required: required,
|
||||
Completion: clonePendingTurnCompletion(completion),
|
||||
}
|
||||
if completion != nil {
|
||||
stream.Phase = TurnPhaseCheckpointing
|
||||
}
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
|
||||
for _, write := range toWrite {
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||
Message: buildSetCheckpointBlobMessage(write.requestID, write.blob),
|
||||
}); err != nil {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish checkpoint blob: %w", err))
|
||||
}
|
||||
}
|
||||
if service.checkpointProjectionReady(stream) {
|
||||
return service.publishReadyCheckpoint(stream)
|
||||
}
|
||||
service.scheduleStreamTimer(
|
||||
stream,
|
||||
providerTimerKey(streamTimerCheckpointBlobs, ""),
|
||||
checkpointBlobWriteTimeout,
|
||||
streamTimerCheckpointBlobs,
|
||||
"",
|
||||
0,
|
||||
"checkpoint blob write timeout",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if stream.PendingCheckpoint == nil {
|
||||
return false
|
||||
}
|
||||
for key := range stream.PendingCheckpoint.Required {
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
|
||||
if service == nil || stream == nil || message == nil || message.GetSetBlobResult() == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
key, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
|
||||
if ok {
|
||||
delete(stream.PendingCheckpointBlobWrites, message.GetId())
|
||||
}
|
||||
required := false
|
||||
if ok && stream.PendingCheckpoint != nil {
|
||||
_, required = stream.PendingCheckpoint.Required[key]
|
||||
}
|
||||
if ok && message.GetSetBlobResult().GetError() == nil {
|
||||
stream.ConfirmedCheckpointBlobs[key] = struct{}{}
|
||||
}
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if blobErr := message.GetSetBlobResult().GetError(); blobErr != nil && required {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf(
|
||||
"client rejected checkpoint blob %s: %s",
|
||||
hex.EncodeToString([]byte(key)),
|
||||
firstNonEmpty(strings.TrimSpace(blobErr.GetMessage()), "unknown error"),
|
||||
))
|
||||
}
|
||||
if service.checkpointProjectionReady(stream) {
|
||||
return service.publishReadyCheckpoint(stream)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
|
||||
if service == nil || stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
if pending == nil {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
for key := range pending.Required {
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
stream.PendingCheckpoint = nil
|
||||
state := pending.State
|
||||
completion := clonePendingTurnCompletion(pending.Completion)
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
if completion != nil {
|
||||
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pendingCount := len(stream.PendingCheckpointBlobWrites)
|
||||
stream.mu.Unlock()
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
|
||||
}
|
||||
|
||||
func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, cause error) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
stream.PendingCheckpoint = nil
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if cause != nil {
|
||||
log.Printf("forwarder checkpoint blob sync skipped request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||
}
|
||||
if pending != nil && pending.Completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *pending.Completion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
stream.mu.Lock()
|
||||
stream.PendingCheckpoint = nil
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s reason=%s", stream.RequestID, stream.ConversationID, strings.TrimSpace(reason))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
if len(events) != len(projection.Blobs) {
|
||||
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs))
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
|
||||
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
events = readCheckpointTestEvents(t, service, stream)
|
||||
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
|
||||
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
|
||||
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
completion := &pendingTurnCompletion{
|
||||
RequestID: stream.RequestID,
|
||||
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||
}
|
||||
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
|
||||
for index, event := range events {
|
||||
switch {
|
||||
case event.Message.GetConversationCheckpointUpdate() != nil:
|
||||
checkpointIndex = index
|
||||
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
|
||||
turnEndedIndex = index
|
||||
case event.End:
|
||||
endIndex = index
|
||||
}
|
||||
}
|
||||
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
|
||||
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
completion := &pendingTurnCompletion{
|
||||
RequestID: stream.RequestID,
|
||||
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||
}
|
||||
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
|
||||
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
|
||||
}
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
var checkpoint, turnEnded, successfulEnd bool
|
||||
for _, event := range events {
|
||||
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||
turnEnded = turnEnded || event.Message.GetInteractionUpdate().GetTurnEnded() != nil
|
||||
successfulEnd = successfulEnd || event.End && event.TerminalErrorCode == ""
|
||||
}
|
||||
if checkpoint || !turnEnded || !successfulEnd {
|
||||
t.Fatalf("timeout events checkpoint=%v turn_ended=%v successful_end=%v", checkpoint, turnEnded, successfulEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||
requestIDs = append(requestIDs, requestID)
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
if err := service.handleCancelIntent(InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "user stopped",
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||
}
|
||||
for _, requestID := range requestIDs {
|
||||
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||
Id: requestID,
|
||||
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||
SetBlobResult: &agentv1.SetBlobResult{},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("late ACK %d error = %v", requestID, err)
|
||||
}
|
||||
}
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
var checkpoint, canceledEnd bool
|
||||
for _, event := range events {
|
||||
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
stream.mu.Unlock()
|
||||
if checkpoint || !canceledEnd || pending != nil {
|
||||
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil)
|
||||
}
|
||||
}
|
||||
|
||||
func testCheckpointBlobProjection(t *testing.T) (*Service, *ActiveStream, *CheckpointProjection) {
|
||||
t.Helper()
|
||||
broker := NewStreamBroker()
|
||||
service := &Service{
|
||||
store: NewConversationFileStore(t.TempDir()),
|
||||
projector: NewHistoryProjector(),
|
||||
broker: broker,
|
||||
}
|
||||
stream, err := broker.OpenStream(
|
||||
"request-1", "conversation-1", 1, "default", "default",
|
||||
agentv1.AgentMode_AGENT_MODE_AGENT, "hello",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStream() error = %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
testCheckpointUserEntry(t),
|
||||
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||
},
|
||||
}
|
||||
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
|
||||
t.Fatalf("replaceCheckpointConversation() error = %v", err)
|
||||
}
|
||||
return service, stream, projection
|
||||
}
|
||||
|
||||
func testCheckpointUserEntry(t *testing.T) HistoryEntry {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: "hello", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
return HistoryEntry{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: payload}
|
||||
}
|
||||
|
||||
func acknowledgeCheckpointBlobs(t *testing.T, service *Service, stream *ActiveStream) {
|
||||
t.Helper()
|
||||
for {
|
||||
stream.mu.Lock()
|
||||
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||
requestIDs = append(requestIDs, requestID)
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
if len(requestIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, requestID := range requestIDs {
|
||||
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||
Id: requestID,
|
||||
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||
SetBlobResult: &agentv1.SetBlobResult{},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", requestID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCheckpointTestEvents(t *testing.T, service *Service, stream *ActiveStream) []StreamEvent {
|
||||
t.Helper()
|
||||
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -245,6 +245,22 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
|
||||
}
|
||||
}
|
||||
|
||||
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
|
||||
return &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_KvServerMessage{
|
||||
KvServerMessage: &agentv1.KvServerMessage{
|
||||
Id: id,
|
||||
Message: &agentv1.KvServerMessage_SetBlobArgs{
|
||||
SetBlobArgs: &agentv1.SetBlobArgs{
|
||||
BlobId: append([]byte(nil), blob.ID...),
|
||||
BlobData: append([]byte(nil), blob.Data...),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
|
||||
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
||||
return &agentv1.AgentServerMessage{
|
||||
|
||||
@@ -690,9 +690,21 @@ func appendEntriesInPlace(conversation *ConversationFile, entries []HistoryEntry
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
assigned := make([]HistoryEntry, 0, len(entries))
|
||||
existingIdempotencyKeys := make(map[string]struct{})
|
||||
for _, existing := range conversation.Entries {
|
||||
if key := strings.TrimSpace(existing.IdempotencyKey); key != "" {
|
||||
existingIdempotencyKeys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
maxTurnSeq := conversation.NextTurnSeq - 1
|
||||
for _, entry := range entries {
|
||||
next := entry
|
||||
if key := strings.TrimSpace(next.IdempotencyKey); key != "" {
|
||||
if _, exists := existingIdempotencyKeys[key]; exists {
|
||||
continue
|
||||
}
|
||||
existingIdempotencyKeys[key] = struct{}{}
|
||||
}
|
||||
if next.CreatedAt.IsZero() {
|
||||
next.CreatedAt = now
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendEntriesDeduplicatesIdempotencyKey(t *testing.T) {
|
||||
store := NewConversationFileStore(t.TempDir())
|
||||
entry := HistoryEntry{
|
||||
TurnSeq: 1,
|
||||
RequestID: "request-1",
|
||||
IdempotencyKey: "provider-interrupted-output:test",
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: json.RawMessage(`{"text":"partial"}`),
|
||||
}
|
||||
|
||||
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||
t.Fatalf("first AppendEntries() error = %v", err)
|
||||
} else if len(assigned) != 1 {
|
||||
t.Fatalf("first AppendEntries() assigned = %d, want 1", len(assigned))
|
||||
}
|
||||
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||
t.Fatalf("duplicate AppendEntries() error = %v", err)
|
||||
} else if len(assigned) != 0 {
|
||||
t.Fatalf("duplicate AppendEntries() assigned = %d, want 0", len(assigned))
|
||||
}
|
||||
|
||||
conversation, err := store.LoadConversation("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
if len(conversation.Entries) != 1 {
|
||||
t.Fatalf("persisted entries = %d, want 1", len(conversation.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPersistsInterruptedProviderOutputIdempotently(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
stream.CurrentModelCallID = "model-call-1"
|
||||
stream.ProviderAccumulatedText = "partial answer"
|
||||
stream.ProviderAccumulatedReasoning = "partial reasoning"
|
||||
stream.mu.Unlock()
|
||||
|
||||
cancel := InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "[canceled] Superseded by newer request",
|
||||
}
|
||||
if err := service.handleCancelIntent(cancel); err != nil {
|
||||
t.Fatalf("first handleCancelIntent() error = %v", err)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
stream.ProviderAccumulatedText = "late duplicate fragment"
|
||||
stream.mu.Unlock()
|
||||
if err := service.handleCancelIntent(cancel); err != nil {
|
||||
t.Fatalf("duplicate handleCancelIntent() error = %v", err)
|
||||
}
|
||||
|
||||
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
assistantEntries := 0
|
||||
cancelEntries := 0
|
||||
for _, entry := range persisted.Entries {
|
||||
if entry.Kind == "metadata" {
|
||||
var payload metadataPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode metadata entry: %v", err)
|
||||
}
|
||||
if payload.Type == "control" && readStringValue(payload.Value["status"]) == "canceled" {
|
||||
cancelEntries++
|
||||
}
|
||||
}
|
||||
if entry.Kind != "assistant_text" {
|
||||
continue
|
||||
}
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode assistant entry: %v", err)
|
||||
}
|
||||
if payload.Text == "partial answer" {
|
||||
assistantEntries++
|
||||
}
|
||||
}
|
||||
if assistantEntries != 1 {
|
||||
t.Fatalf("persisted interrupted assistant entries = %d, want 1", assistantEntries)
|
||||
}
|
||||
if cancelEntries != 1 {
|
||||
t.Fatalf("persisted cancel metadata entries = %d, want 1", cancelEntries)
|
||||
}
|
||||
|
||||
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "partial answer" && strings.TrimSpace(message.ReasoningContent) == "partial reasoning" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("replay = %#v, want interrupted assistant output", replay)
|
||||
}
|
||||
checkpoint, err := service.projector.ProjectCheckpointProjection(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if checkpoint == nil || checkpoint.State == nil || len(checkpoint.State.GetTurns()) != 1 {
|
||||
t.Fatalf("checkpoint state = %#v, want interrupted turn", checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if err := service.handleCancelIntent(InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "new_message_submitted",
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||
}
|
||||
|
||||
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "hi" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("replay = %#v, want persisted assistant activity", replay)
|
||||
}
|
||||
|
||||
func TestProjectPromptReplayPreservesLegacyCanceledTurnActivity(t *testing.T) {
|
||||
cancelEntry := newMetadataEntry(1, "request-1", "control", map[string]any{
|
||||
"status": "canceled",
|
||||
"reason": "new_message_submitted",
|
||||
"replay_policy": cancelReplayPolicyKeepStableInput,
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newAssistantTextEntry(1, "request-1", "persisted activity", "", ""),
|
||||
cancelEntry,
|
||||
},
|
||||
}
|
||||
|
||||
replay, err := NewHistoryProjector().ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "persisted activity" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("replay = %#v, want legacy canceled activity", replay)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
|
||||
type HistoryProjector struct {
|
||||
}
|
||||
|
||||
type CheckpointBlob struct {
|
||||
ID []byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type CheckpointProjection struct {
|
||||
State *agentv1.ConversationStateStructure
|
||||
Blobs []CheckpointBlob
|
||||
}
|
||||
|
||||
type checkpointBlobGraph struct {
|
||||
blobs map[[sha256.Size]byte][]byte
|
||||
order [][sha256.Size]byte
|
||||
}
|
||||
|
||||
func newCheckpointBlobGraph() *checkpointBlobGraph {
|
||||
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
|
||||
}
|
||||
|
||||
func (graph *checkpointBlobGraph) add(data []byte) []byte {
|
||||
if graph == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
id := sha256.Sum256(data)
|
||||
if _, exists := graph.blobs[id]; !exists {
|
||||
graph.blobs[id] = append([]byte(nil), data...)
|
||||
graph.order = append(graph.order, id)
|
||||
}
|
||||
return append([]byte(nil), id[:]...)
|
||||
}
|
||||
|
||||
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
|
||||
if graph == nil || len(graph.order) == 0 {
|
||||
return nil
|
||||
}
|
||||
blobs := make([]CheckpointBlob, 0, len(graph.order))
|
||||
for _, id := range graph.order {
|
||||
blobs = append(blobs, CheckpointBlob{
|
||||
ID: append([]byte(nil), id[:]...),
|
||||
Data: append([]byte(nil), graph.blobs[id]...),
|
||||
})
|
||||
}
|
||||
return blobs
|
||||
}
|
||||
|
||||
// NewHistoryProjector 创建 history 投影器。
|
||||
func NewHistoryProjector() *HistoryProjector {
|
||||
return &HistoryProjector{}
|
||||
@@ -309,6 +355,7 @@ const (
|
||||
cancelReplayPolicyDropTurn = "drop_turn"
|
||||
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
||||
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
||||
cancelReplayPolicyKeepInterrupted = "keep_interrupted_output"
|
||||
)
|
||||
|
||||
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||
@@ -324,13 +371,19 @@ func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||
for _, entry := range entries {
|
||||
if entry.TurnSeq > 0 {
|
||||
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
||||
if policy == cancelReplayPolicyDropUnstarted {
|
||||
if policy == cancelReplayPolicyKeepInterrupted {
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
if policy != cancelReplayPolicyDropTurn {
|
||||
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
||||
policy = cancelReplayPolicyKeepStableInput
|
||||
} else {
|
||||
policy = cancelReplayPolicyDropTurn
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if policy == cancelReplayPolicyDropUnstarted {
|
||||
policy = cancelReplayPolicyDropTurn
|
||||
}
|
||||
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
||||
continue
|
||||
}
|
||||
@@ -404,6 +457,8 @@ func normalizeCancelReplayPolicy(policy string, reason string) string {
|
||||
return cancelReplayPolicyDropUnstarted
|
||||
case cancelReplayPolicyKeepStableInput:
|
||||
return cancelReplayPolicyKeepStableInput
|
||||
case cancelReplayPolicyKeepInterrupted:
|
||||
return cancelReplayPolicyKeepInterrupted
|
||||
default:
|
||||
return cancelReplayPolicyForReason(reason)
|
||||
}
|
||||
@@ -475,6 +530,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
|
||||
|
||||
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
||||
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
|
||||
projection, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil || projection == nil {
|
||||
return nil, err
|
||||
}
|
||||
return projection.State, nil
|
||||
}
|
||||
|
||||
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
|
||||
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
|
||||
blobs := newCheckpointBlobGraph()
|
||||
state := &agentv1.ConversationStateStructure{
|
||||
TokenDetails: &agentv1.ConversationTokenDetails{
|
||||
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
||||
@@ -488,7 +553,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
if conversation == nil {
|
||||
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
||||
state.Mode = &mode
|
||||
return state, nil
|
||||
return &CheckpointProjection{State: state}, nil
|
||||
}
|
||||
mode, err := parseModeAlias(conversation.Mode)
|
||||
if err != nil {
|
||||
@@ -506,158 +571,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
if structuredState.HasTodos {
|
||||
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
||||
}
|
||||
grouped := make(map[int64][]HistoryEntry)
|
||||
order := make([]int64, 0, conversation.NextTurnSeq)
|
||||
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
||||
if entry.TurnSeq <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := grouped[entry.TurnSeq]; !ok {
|
||||
order = append(order, entry.TurnSeq)
|
||||
}
|
||||
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
||||
}
|
||||
|
||||
for _, turnSeq := range order {
|
||||
entries := grouped[turnSeq]
|
||||
var rawUserMessage []byte
|
||||
var turnRequestID string
|
||||
steps := make([][]byte, 0, len(entries))
|
||||
seenToolCalls := make(map[string]struct{})
|
||||
openToolCalls := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if turnRequestID == "" {
|
||||
turnRequestID = strings.TrimSpace(entry.RequestID)
|
||||
}
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
||||
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
||||
}
|
||||
payload, err := proto.Marshal(userMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawUserMessage = payload
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{
|
||||
ToolCall: toolCall,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
||||
seenToolCalls[toolCallID] = struct{}{}
|
||||
openToolCalls[toolCallID] = struct{}{}
|
||||
}
|
||||
case "tool_result":
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||
delete(openToolCalls, toolCallID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
if len(payload.ToolCall) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{
|
||||
ToolCall: toolCall,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
}
|
||||
if len(rawUserMessage) == 0 && len(steps) == 0 {
|
||||
continue
|
||||
}
|
||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: rawUserMessage,
|
||||
Steps: steps,
|
||||
}
|
||||
if turnRequestID != "" {
|
||||
agentTurn.RequestId = &turnRequestID
|
||||
}
|
||||
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: agentTurn,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Turns = append(state.Turns, turnPayload)
|
||||
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Turns = turnIDs
|
||||
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -685,15 +603,191 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
return nil, err
|
||||
}
|
||||
state.RootPromptMessagesJson = rootPromptMessages
|
||||
return state, nil
|
||||
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
|
||||
}
|
||||
|
||||
func marshalThinkingStep(text string) ([]byte, error) {
|
||||
return proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
|
||||
},
|
||||
})
|
||||
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
|
||||
if conversation == nil || blobs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
grouped := make(map[int64][]HistoryEntry)
|
||||
order := make([]int64, 0, conversation.NextTurnSeq)
|
||||
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
||||
if entry.TurnSeq <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := grouped[entry.TurnSeq]; !ok {
|
||||
order = append(order, entry.TurnSeq)
|
||||
}
|
||||
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
||||
}
|
||||
|
||||
turnIDs := make([][]byte, 0, len(order))
|
||||
for _, turnSeq := range order {
|
||||
entries := grouped[turnSeq]
|
||||
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var userMessageID []byte
|
||||
var turnRequestID string
|
||||
steps := make([]*agentv1.ConversationStep, 0, len(entries))
|
||||
seenToolCalls := make(map[string]struct{})
|
||||
openToolCalls := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if turnRequestID == "" {
|
||||
turnRequestID = strings.TrimSpace(entry.RequestID)
|
||||
}
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
||||
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
||||
}
|
||||
payload, err := proto.Marshal(userMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userMessageID = blobs.add(payload)
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||
},
|
||||
})
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedPayload := completedToolCalls[toolCallID]; len(completedPayload) > 0 {
|
||||
completedToolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(completedPayload, completedToolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proto.Merge(toolCall, completedToolCall)
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||
})
|
||||
if toolCallID != "" {
|
||||
seenToolCalls[toolCallID] = struct{}{}
|
||||
openToolCalls[toolCallID] = struct{}{}
|
||||
}
|
||||
case "tool_result":
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||
if toolCallID != "" {
|
||||
delete(openToolCalls, toolCallID)
|
||||
}
|
||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(payload.ToolCall) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(userMessageID) == 0 && len(steps) == 0 {
|
||||
continue
|
||||
}
|
||||
stepIDs := make([][]byte, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
stepID, err := addCheckpointStepBlob(blobs, step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stepIDs = append(stepIDs, stepID)
|
||||
}
|
||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: userMessageID,
|
||||
Steps: stepIDs,
|
||||
}
|
||||
if turnRequestID != "" {
|
||||
agentTurn.RequestId = &turnRequestID
|
||||
}
|
||||
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: agentTurn,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
turnIDs = append(turnIDs, blobs.add(turnPayload))
|
||||
}
|
||||
return turnIDs, nil
|
||||
}
|
||||
|
||||
func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
|
||||
completed := make(map[string]json.RawMessage)
|
||||
for _, entry := range entries {
|
||||
if strings.TrimSpace(entry.Kind) != "tool_result" {
|
||||
continue
|
||||
}
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" && len(payload.ToolCall) > 0 {
|
||||
completed[toolCallID] = payload.ToolCall
|
||||
}
|
||||
}
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
|
||||
payload, err := proto.Marshal(step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blobs.add(payload), nil
|
||||
}
|
||||
|
||||
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
|
||||
@@ -1132,7 +1226,7 @@ func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []mod
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func shouldPersistToolResultName(toolName string) bool {
|
||||
func shouldPersistCheckpointReplayToolResultName(toolName string) bool {
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
||||
return true
|
||||
@@ -1141,60 +1235,6 @@ func shouldPersistToolResultName(toolName string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func filterCheckpointTurns(rawTurns [][]byte) [][]byte {
|
||||
if len(rawTurns) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([][]byte, 0, len(rawTurns))
|
||||
for _, rawTurn := range rawTurns {
|
||||
if len(rawTurn) == 0 {
|
||||
continue
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
|
||||
nextSteps := make([][]byte, 0, len(agentTurn.GetSteps()))
|
||||
for _, rawStep := range agentTurn.GetSteps() {
|
||||
if len(rawStep) == 0 {
|
||||
continue
|
||||
}
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
||||
continue
|
||||
}
|
||||
if toolCall := step.GetToolCall(); toolCall != nil && !shouldPersistToolResultName(inferToolName(toolCall)) {
|
||||
continue
|
||||
}
|
||||
nextSteps = append(nextSteps, append([]byte(nil), rawStep...))
|
||||
}
|
||||
if len(agentTurn.GetUserMessage()) == 0 && len(nextSteps) == 0 {
|
||||
continue
|
||||
}
|
||||
encoded, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: append([]byte(nil), agentTurn.GetUserMessage()...),
|
||||
Steps: nextSteps,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, encoded)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
@@ -1205,7 +1245,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
if !shouldPersistToolResultName(toolCall.Function.Name) {
|
||||
if !shouldPersistCheckpointReplayToolResultName(toolCall.Function.Name) {
|
||||
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
||||
continue
|
||||
}
|
||||
@@ -1222,7 +1262,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
||||
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
||||
continue
|
||||
}
|
||||
if !shouldPersistToolResultName(message.Name) {
|
||||
if !shouldPersistCheckpointReplayToolResultName(message.Name) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
)
|
||||
|
||||
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
|
||||
Text: "parent question",
|
||||
MessageId: "message-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
state := projection.State
|
||||
if len(state.GetTurns()) != 1 {
|
||||
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob-backed turn", len(state.GetTurns()))
|
||||
}
|
||||
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
digest := sha256.Sum256(blob.Data)
|
||||
if len(blob.ID) != sha256.Size || string(blob.ID) != string(digest[:]) {
|
||||
t.Fatalf("invalid content-addressed Blob id=%x", blob.ID)
|
||||
}
|
||||
blobs[string(blob.ID)] = blob.Data
|
||||
}
|
||||
turnPayload, ok := blobs[string(state.GetTurns()[0])]
|
||||
if !ok {
|
||||
t.Fatal("turn references a missing Blob")
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(turnPayload, turn); err != nil {
|
||||
t.Fatalf("decode turn Blob: %v", err)
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
t.Fatal("turn Blob does not contain an agent turn")
|
||||
}
|
||||
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
|
||||
t.Fatal("turn references a missing user message Blob")
|
||||
}
|
||||
for _, stepID := range agentTurn.GetSteps() {
|
||||
if _, ok := blobs[string(stepID)]; !ok {
|
||||
t.Fatal("turn references a missing step Blob")
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := importedConversationStateModelMessages(state)
|
||||
if err != nil {
|
||||
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages))
|
||||
}
|
||||
if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") {
|
||||
t.Fatalf("first imported message = %#v", messages[0])
|
||||
}
|
||||
if messages[1].Role != "assistant" || messages[1].Content != "parent answer" {
|
||||
t.Fatalf("second imported message = %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
|
||||
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal first user message: %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: firstUser},
|
||||
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||
},
|
||||
}
|
||||
projector := NewHistoryProjector()
|
||||
midpoint, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("midpoint projection: %v", err)
|
||||
}
|
||||
|
||||
secondUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "second question", MessageId: "message-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal second user message: %v", err)
|
||||
}
|
||||
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||
{TurnSeq: 2, RequestID: "request-2", Role: "user", Kind: "user_message", Payload: secondUser},
|
||||
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||
})
|
||||
latest, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("latest projection: %v", err)
|
||||
}
|
||||
|
||||
midpointMessages, err := importedConversationStateModelMessages(midpoint.State)
|
||||
if err != nil {
|
||||
t.Fatalf("import midpoint messages: %v", err)
|
||||
}
|
||||
latestMessages, err := importedConversationStateModelMessages(latest.State)
|
||||
if err != nil {
|
||||
t.Fatalf("import latest messages: %v", err)
|
||||
}
|
||||
if len(midpoint.State.GetTurns()) != 1 || len(midpointMessages) != 2 {
|
||||
t.Fatalf("midpoint turns=%d messages=%d, want 1 turn and 2 messages", len(midpoint.State.GetTurns()), len(midpointMessages))
|
||||
}
|
||||
if len(latest.State.GetTurns()) != 2 || len(latestMessages) != 4 {
|
||||
t.Fatalf("latest turns=%d messages=%d, want 2 turns and 4 messages", len(latest.State.GetTurns()), len(latestMessages))
|
||||
}
|
||||
if midpointMessages[1].Content != "first answer" || latestMessages[3].Content != "second answer" {
|
||||
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionMergesToolCallWithCompletedResult(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
startedAt := uint64(100)
|
||||
toolCallID := "call-1"
|
||||
startedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
ToolCallId: &toolCallID,
|
||||
StartedAtMs: &startedAt,
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||
},
|
||||
},
|
||||
})
|
||||
completedAt := uint64(200)
|
||||
completedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
CompletedAtMs: &completedAt,
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Result: &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
Path: "/tmp/example.txt",
|
||||
TotalLines: 1,
|
||||
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newAssistantTextEntry(1, "request-1", "before", "", ""),
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||
newAssistantTextEntry(1, "request-1", "after", "", ""),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(projection.Blobs) != 5 {
|
||||
t.Fatalf("checkpoint blobs = %d, want user, three final steps, and turn", len(projection.Blobs))
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("checkpoint steps = %d, want assistant, completed Read, assistant", len(steps))
|
||||
}
|
||||
if steps[0].GetAssistantMessage().GetText() != "before" || steps[2].GetAssistantMessage().GetText() != "after" {
|
||||
t.Fatalf("checkpoint step ordering changed: %#v", steps)
|
||||
}
|
||||
mergedToolCall := steps[1].GetToolCall()
|
||||
readCall := mergedToolCall.GetReadToolCall()
|
||||
if readCall == nil || readCall.GetResult().GetSuccess().GetContent() != "file contents" {
|
||||
t.Fatalf("checkpoint Read step does not contain completed result: %#v", steps[1].GetToolCall())
|
||||
}
|
||||
if readCall.GetArgs().GetPath() != "/tmp/example.txt" || mergedToolCall.GetToolCallId() != toolCallID || mergedToolCall.GetStartedAtMs() != startedAt || mergedToolCall.GetCompletedAtMs() != completedAt {
|
||||
t.Fatalf("checkpoint Read step lost started-call fields: %#v", mergedToolCall)
|
||||
}
|
||||
|
||||
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||
if err != nil {
|
||||
t.Fatalf("decode root prompt replay: %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Name == "Read" || len(message.ToolCalls) > 0 {
|
||||
t.Fatalf("UI-only Read result leaked into root prompt replay: %#v", replay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionIsIdempotentAndDoesNotMutateHistory(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
Path: "/tmp/example.txt",
|
||||
TotalLines: 1,
|
||||
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||
},
|
||||
}
|
||||
before, err := json.Marshal(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation before projection: %v", err)
|
||||
}
|
||||
|
||||
projector := NewHistoryProjector()
|
||||
first, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("first projection: %v", err)
|
||||
}
|
||||
second, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("second projection: %v", err)
|
||||
}
|
||||
|
||||
if !proto.Equal(first.State, second.State) {
|
||||
t.Fatalf("repeated projection changed checkpoint state: first=%#v second=%#v", first.State, second.State)
|
||||
}
|
||||
if len(first.Blobs) != len(second.Blobs) {
|
||||
t.Fatalf("repeated projection changed blob count: first=%d second=%d", len(first.Blobs), len(second.Blobs))
|
||||
}
|
||||
for index := range first.Blobs {
|
||||
if !bytes.Equal(first.Blobs[index].ID, second.Blobs[index].ID) || !bytes.Equal(first.Blobs[index].Data, second.Blobs[index].Data) {
|
||||
t.Fatalf("repeated projection changed blob %d", index)
|
||||
}
|
||||
}
|
||||
after, err := json.Marshal(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation after projection: %v", err)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatalf("checkpoint projection mutated semantic history:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissing(t *testing.T) {
|
||||
startedToolCall := checkpointTestReadToolCall(t, nil)
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("checkpoint steps = %d, want the original Read step", len(steps))
|
||||
}
|
||||
readCall := steps[0].GetToolCall().GetReadToolCall()
|
||||
if readCall == nil || readCall.GetArgs().GetPath() != "/tmp/example.txt" || readCall.GetResult() != nil {
|
||||
t.Fatalf("checkpoint did not preserve the original Read call: %#v", steps[0].GetToolCall())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *testing.T) {
|
||||
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Error{Error: &agentv1.ReadToolError{ErrorMessage: "not readable"}},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 1 || steps[0].GetToolCall().GetReadToolCall().GetResult().GetError().GetErrorMessage() != "not readable" {
|
||||
t.Fatalf("legacy result-only Read step was not preserved: %#v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func checkpointTestReadToolCall(t *testing.T, result *agentv1.ReadToolResult) []byte {
|
||||
t.Helper()
|
||||
return checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||
Result: result,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func checkpointTestToolCallPayload(t *testing.T, toolCall *agentv1.ToolCall) []byte {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(toolCall)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Read tool call: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func checkpointProjectionSteps(t *testing.T, projection *CheckpointProjection) []*agentv1.ConversationStep {
|
||||
t.Helper()
|
||||
if projection == nil || projection.State == nil || len(projection.State.GetTurns()) != 1 {
|
||||
t.Fatalf("checkpoint turns = %#v, want exactly one turn", projection)
|
||||
}
|
||||
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
blobs[string(blob.ID)] = blob.Data
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(blobs[string(projection.State.GetTurns()[0])], turn); err != nil {
|
||||
t.Fatalf("decode checkpoint turn: %v", err)
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
t.Fatal("checkpoint turn does not contain an agent turn")
|
||||
}
|
||||
steps := make([]*agentv1.ConversationStep, 0, len(agentTurn.GetSteps()))
|
||||
for _, stepID := range agentTurn.GetSteps() {
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(blobs[string(stepID)], step); err != nil {
|
||||
t.Fatalf("decode checkpoint step: %v", err)
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -834,14 +836,22 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
}
|
||||
hasCheckpoint := checkpointConversationInitialized(stream)
|
||||
if hasCheckpoint {
|
||||
preservedInterruptedOutput, err := service.persistInterruptedProviderOutput(stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||
"status": "canceled",
|
||||
"reason": cancelReason,
|
||||
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
||||
}),
|
||||
replayPolicy := cancelReplayPolicyForReason(cancelReason)
|
||||
if preservedInterruptedOutput || checkpointTurnHasReplayActivity(stream) {
|
||||
replayPolicy = cancelReplayPolicyKeepInterrupted
|
||||
}
|
||||
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||
"status": "canceled",
|
||||
"reason": cancelReason,
|
||||
"replay_policy": replayPolicy,
|
||||
})
|
||||
cancelEntry.IdempotencyKey = cancelMetadataIdempotencyKey(stream.TurnSeq, intent.RequestID)
|
||||
_, err = service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -858,9 +868,7 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
})
|
||||
}
|
||||
if hasCheckpoint {
|
||||
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
service.discardPendingCheckpoint(stream, "checkpoint superseded by cancellation")
|
||||
}
|
||||
clearPendingProviderCompletion(stream)
|
||||
stream.mu.Lock()
|
||||
@@ -871,6 +879,89 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
||||
}
|
||||
|
||||
func checkpointTurnHasReplayActivity(stream *ActiveStream) bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if stream.CheckpointConversation == nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range stream.CheckpointConversation.Entries {
|
||||
if entry.TurnSeq == stream.TurnSeq && isCanceledTurnActivityEntry(entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// persistInterruptedProviderOutput commits the current provider pass before cancellation.
|
||||
// The entry key is stable for this provider pass, so repeated cancellation handling is a no-op.
|
||||
func (service *Service) persistInterruptedProviderOutput(stream *ActiveStream) (bool, error) {
|
||||
if stream == nil {
|
||||
return false, nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
turnSeq := stream.TurnSeq
|
||||
requestID := strings.TrimSpace(stream.RequestID)
|
||||
modelCallID := strings.TrimSpace(stream.CurrentModelCallID)
|
||||
providerPass := stream.ProviderPassCount
|
||||
text := stream.ProviderAccumulatedText
|
||||
reasoning := stream.ProviderAccumulatedReasoning
|
||||
reasoningSignature := stream.ProviderAccumulatedReasoningSignature
|
||||
reasoningSignatureSource := stream.ProviderAccumulatedReasoningSignatureSource
|
||||
reasoningItemID := stream.ProviderAccumulatedReasoningItemID
|
||||
reasoningStatus := stream.ProviderAccumulatedReasoningStatus
|
||||
reasoningSummary := append([]byte(nil), stream.ProviderAccumulatedReasoningSummary...)
|
||||
stream.mu.Unlock()
|
||||
if strings.TrimSpace(text) == "" && !hasReplayableReasoningPayload(reasoning, reasoningSignature, reasoningSignatureSource) {
|
||||
return false, nil
|
||||
}
|
||||
key := interruptedProviderOutputIdempotencyKey(turnSeq, requestID, modelCallID, providerPass)
|
||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||
{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: requestID,
|
||||
IdempotencyKey: key,
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: newAssistantTextPayload(
|
||||
text,
|
||||
reasoning,
|
||||
reasoningSignature,
|
||||
reasoningSignatureSource,
|
||||
reasoningItemID,
|
||||
reasoningStatus,
|
||||
reasoningSummary,
|
||||
),
|
||||
},
|
||||
})
|
||||
return true, err
|
||||
}
|
||||
|
||||
func interruptedProviderOutputIdempotencyKey(turnSeq int64, requestID string, modelCallID string, providerPass int) string {
|
||||
payload := strings.Join([]string{
|
||||
"provider_interrupted_output",
|
||||
fmt.Sprintf("%d", turnSeq),
|
||||
strings.TrimSpace(requestID),
|
||||
strings.TrimSpace(modelCallID),
|
||||
fmt.Sprintf("%d", providerPass),
|
||||
}, "\x00")
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "provider-interrupted-output:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func cancelMetadataIdempotencyKey(turnSeq int64, requestID string) string {
|
||||
payload := strings.Join([]string{
|
||||
"cancel",
|
||||
fmt.Sprintf("%d", turnSeq),
|
||||
strings.TrimSpace(requestID),
|
||||
}, "\x00")
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "cancel:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
||||
func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||
stream, ok := service.broker.Get(intent.RequestID)
|
||||
@@ -2122,9 +2213,15 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := service.publishCheckpoint(requestID, conversationID); err != nil {
|
||||
return err
|
||||
return service.publishCheckpointWithCompletion(requestID, conversationID, &completion)
|
||||
}
|
||||
|
||||
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
|
||||
usage := completion.Usage
|
||||
if err := service.broker.Publish(requestID, StreamEvent{
|
||||
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
||||
}); err != nil {
|
||||
@@ -2151,7 +2248,11 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
|
||||
}
|
||||
|
||||
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
|
||||
func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
||||
func (service *Service) publishCheckpoint(requestID string, conversationID string) error {
|
||||
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
|
||||
}
|
||||
|
||||
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
|
||||
stream, ok := service.broker.Get(requestID)
|
||||
if !ok || stream == nil {
|
||||
return fmt.Errorf("request is not active: %s", requestID)
|
||||
@@ -2160,15 +2261,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
|
||||
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
|
||||
return service.broker.Publish(requestID, StreamEvent{
|
||||
Message: buildCheckpointMessage(state),
|
||||
})
|
||||
if projection == nil || projection.State == nil {
|
||||
return fmt.Errorf("checkpoint projection is empty")
|
||||
}
|
||||
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
|
||||
return service.queueCheckpointProjection(stream, projection, completion)
|
||||
}
|
||||
|
||||
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
||||
@@ -2422,6 +2524,16 @@ func newAssistantTextEntry(turnSeq int64, requestID string, text string, reasoni
|
||||
}
|
||||
|
||||
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: newAssistantTextPayload(text, reasoningContent, reasoningSignature, reasoningSignatureSource, reasoningItemID, reasoningStatus, reasoningSummary),
|
||||
}
|
||||
}
|
||||
|
||||
func newAssistantTextPayload(text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) json.RawMessage {
|
||||
payload, _ := json.Marshal(assistantTextPayload{
|
||||
Text: text,
|
||||
ReasoningContent: reasoningContent,
|
||||
@@ -2431,13 +2543,7 @@ func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string,
|
||||
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
||||
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
||||
})
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: payload,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// newToolCallEntry 构造 tool_call entry。
|
||||
|
||||
@@ -79,6 +79,7 @@ type HistoryEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
TurnSeq int64 `json:"turn_seq"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Kind string `json:"kind"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
@@ -163,6 +164,10 @@ type ActiveStream struct {
|
||||
ProviderUsage turnUsageSnapshot
|
||||
ProviderTerminalToolInvocation bool
|
||||
PendingCompaction *PendingCompaction
|
||||
PendingCheckpointBlobWrites map[uint32]string
|
||||
ConfirmedCheckpointBlobs map[string]struct{}
|
||||
NextCheckpointBlobRequestID uint32
|
||||
PendingCheckpoint *pendingCheckpointPublish
|
||||
|
||||
Backlog []StreamEvent
|
||||
Subscribers map[string]*StreamSubscriber
|
||||
@@ -219,6 +224,12 @@ type pendingTurnCompletion struct {
|
||||
Disposition pendingCompletionDisposition
|
||||
}
|
||||
|
||||
type pendingCheckpointPublish struct {
|
||||
State *agentv1.ConversationStateStructure
|
||||
Required map[string]struct{}
|
||||
Completion *pendingTurnCompletion
|
||||
}
|
||||
|
||||
type PendingCompaction struct {
|
||||
Trigger string
|
||||
ContextTokens int64
|
||||
|
||||
@@ -319,6 +319,16 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockBuilder: upstream.ServerConfigMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.ServerConfigService/GetServerConfig",
|
||||
server.Name("server_config_service_get_server_config"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "server_config_service_get_server_config",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetServerConfigResponse",
|
||||
MockBuilder: upstream.ServerConfigMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/AvailableModels",
|
||||
server.Name("available_models"),
|
||||
server.ConnectUnary(),
|
||||
@@ -329,6 +339,36 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockBuilder: upstream.AvailableModelsMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetUsableModels",
|
||||
server.Name("usable_models"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "usable_models",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetUsableModelsResponse",
|
||||
MockBuilder: upstream.UsableModelsMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModelForCli",
|
||||
server.Name("default_model_for_cli"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "default_model_for_cli",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetDefaultModelForCliResponse",
|
||||
MockBuilder: upstream.DefaultModelForCliMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModel",
|
||||
server.Name("default_model"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "default_model",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetDefaultModelResponse",
|
||||
MockBuilder: upstream.DefaultModelMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData",
|
||||
server.Name("default_model_nudge"),
|
||||
server.ConnectUnary(),
|
||||
@@ -359,6 +399,34 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockBuilder: upstream.FirstWindowStatsigDecisionMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AnalyticsService/SubmitLogs",
|
||||
server.Name("analytics_submit_logs"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "analytics_submit_logs",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.SubmitLogsResponse",
|
||||
MockBuilder: upstream.SubmitLogsMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AnalyticsService/TrackEvents",
|
||||
server.Name("analytics_track_events"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "analytics_track_events",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.TrackEventsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/v1/traces",
|
||||
server.Name("otlp_traces"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "otlp_traces",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
),
|
||||
server.POST("/oauth/token",
|
||||
server.Name("oauth_token"),
|
||||
server.HTTP(),
|
||||
@@ -477,6 +545,76 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
}),
|
||||
)),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam",
|
||||
server.Name("dashboard_get_team_admin_settings_or_empty"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_team_admin_settings_or_empty",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetTeamAdminSettingsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTeamReposOrEmptyIfNotInTeam",
|
||||
server.Name("dashboard_get_team_repos_or_empty"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_team_repos_or_empty",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetTeamReposResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/ListMarketplaces",
|
||||
server.Name("dashboard_list_marketplaces"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_list_marketplaces",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.ListMarketplacesResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetGlobalCommands",
|
||||
server.Name("dashboard_get_global_commands"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_global_commands",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetGlobalCommandsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
|
||||
server.Name("dashboard_get_effective_user_plugins"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_effective_user_plugins",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetEffectiveUserPluginsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/RegisterMarketplaceAndPlugins",
|
||||
server.Name("dashboard_register_marketplace_and_plugins"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_register_marketplace_and_plugins",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.RegisterMarketplaceAndPluginsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetCliDownloadUrl",
|
||||
server.Name("dashboard_get_cli_download_url"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_cli_download_url",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetCliDownloadUrlResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetMe",
|
||||
server.Name("dashboard_get_me"),
|
||||
server.ConnectUnary(),
|
||||
|
||||
@@ -193,6 +193,18 @@ func DefaultModelNudgeMockBuilder(reqCtx *RequestContext) (map[string]any, error
|
||||
return buildDefaultModelNudgeDataPayload(reqCtx)
|
||||
}
|
||||
|
||||
func UsableModelsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return buildUsableModelsPayload(reqCtx)
|
||||
}
|
||||
|
||||
func DefaultModelForCliMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return buildDefaultModelForCliPayload(reqCtx)
|
||||
}
|
||||
|
||||
func DefaultModelMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return buildDefaultModelPayload(reqCtx)
|
||||
}
|
||||
|
||||
func BootstrapStatsigMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return buildBootstrapStatsigPayload(reqCtx)
|
||||
}
|
||||
@@ -213,6 +225,18 @@ func DashboardManagedSkillsMockBuilder(reqCtx *RequestContext) (map[string]any,
|
||||
return buildDashboardManagedSkillsPayload(reqCtx)
|
||||
}
|
||||
|
||||
// EmptyMockBuilder возвращает пустой proto-ответ для ручек, где клиенту
|
||||
// достаточно успешного "пусто": нет team-настроек, нет репозиториев,
|
||||
// нет маркетплейсов/плагинов/команд, телеметрия принята без обработки.
|
||||
func EmptyMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// SubmitLogsMockBuilder подтверждает приём логов телеметрии без обработки.
|
||||
func SubmitLogsMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
func DashboardGetMeMockBuilder(reqCtx *RequestContext) (map[string]any, error) {
|
||||
return buildDashboardGetMePayload(reqCtx)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
"cursor/internal/logger"
|
||||
"cursor/internal/netproxy"
|
||||
@@ -426,6 +427,30 @@ func newProtoMessage(typeName string) (proto.Message, error) {
|
||||
return &aiserverv1.GetUsageLimitStatusAndActiveGrantsResponse{}, nil
|
||||
case "aiserver.v1.IsOnNewPricingResponse":
|
||||
return &aiserverv1.IsOnNewPricingResponse{}, nil
|
||||
case "aiserver.v1.GetTeamAdminSettingsResponse":
|
||||
return &aiserverv1.GetTeamAdminSettingsResponse{}, nil
|
||||
case "aiserver.v1.GetTeamReposResponse":
|
||||
return &aiserverv1.GetTeamReposResponse{}, nil
|
||||
case "aiserver.v1.ListMarketplacesResponse":
|
||||
return &aiserverv1.ListMarketplacesResponse{}, nil
|
||||
case "aiserver.v1.GetUsableModelsResponse":
|
||||
return &agentv1.GetUsableModelsResponse{}, nil
|
||||
case "aiserver.v1.GetDefaultModelForCliResponse":
|
||||
return &agentv1.GetDefaultModelForCliResponse{}, nil
|
||||
case "aiserver.v1.GetDefaultModelResponse":
|
||||
return &aiserverv1.GetDefaultModelResponse{}, nil
|
||||
case "aiserver.v1.GetGlobalCommandsResponse":
|
||||
return &aiserverv1.GetGlobalCommandsResponse{}, nil
|
||||
case "aiserver.v1.GetEffectiveUserPluginsResponse":
|
||||
return &aiserverv1.GetEffectiveUserPluginsResponse{}, nil
|
||||
case "aiserver.v1.RegisterMarketplaceAndPluginsResponse":
|
||||
return &aiserverv1.RegisterMarketplaceAndPluginsResponse{}, nil
|
||||
case "aiserver.v1.GetCliDownloadUrlResponse":
|
||||
return &aiserverv1.GetCliDownloadUrlResponse{}, nil
|
||||
case "aiserver.v1.SubmitLogsResponse":
|
||||
return &aiserverv1.SubmitLogsResponse{}, nil
|
||||
case "aiserver.v1.TrackEventsResponse":
|
||||
return &aiserverv1.TrackEventsResponse{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proto message type %q", typeName)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ const (
|
||||
|
||||
modelRuntimeThinkingEffortParameterID = "thinking_effort"
|
||||
|
||||
// localPathEncryptionKey — стабильный ключ шифрования путей для индексации
|
||||
// репозитория, который cursor-agent CLI запрашивает через GetServerConfig
|
||||
// (indexingConfig.default{User,Team}PathEncryptionKey). Без него CLI
|
||||
// не может инициализировать repo identity в git-воркспейсе и вызовы
|
||||
// файловых инструментов падают с "[unimplemented] HTTP 404".
|
||||
localPathEncryptionKey = "6f6e63652d6c6f63616c2d706174682d656e6372797074696f6e2d6b6579"
|
||||
|
||||
localUltraMembershipType = "ultra"
|
||||
localUltraPaymentID = "local_ultra"
|
||||
localUltraSubscriptionStatus = "active"
|
||||
@@ -424,9 +431,13 @@ func buildServerTimePayload(*RequestContext) (map[string]any, error) {
|
||||
|
||||
func buildServerConfigPayload(*RequestContext) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
|
||||
// "http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
|
||||
"configVersion": "local_cli_sandbox_defaults_disabled_v2",
|
||||
"http2Config": "HTTP2_CONFIG_FORCE_ALL_DISABLED",
|
||||
"cliSandboxDefaultEnabled": true,
|
||||
"indexingConfig": map[string]any{
|
||||
"defaultUserPathEncryptionKey": localPathEncryptionKey,
|
||||
"defaultTeamPathEncryptionKey": localPathEncryptionKey,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -440,6 +451,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
|
||||
if len(modelRefs) > 0 {
|
||||
defaultModel = modelRefs[0]
|
||||
}
|
||||
modelEntries := buildAvailableModelEntries(adapters)
|
||||
return map[string]any{
|
||||
"backgroundComposerModelConfig": map[string]any{
|
||||
"bestOfNDefaultModels": append([]string(nil), modelRefs...),
|
||||
@@ -459,7 +471,7 @@ func buildAvailableModelsPayload(reqCtx *RequestContext) (map[string]any, error)
|
||||
"defaultModel": defaultModel,
|
||||
},
|
||||
"disableUnusedModelsAfterNHours": availableModelsDisableUnusedHours,
|
||||
"models": buildAvailableModelEntries(adapters),
|
||||
"models": modelEntries,
|
||||
"planExecutionModelConfig": map[string]any{
|
||||
"defaultModel": defaultModel,
|
||||
"fallbackModels": append([]string(nil), modelRefs...),
|
||||
@@ -486,6 +498,35 @@ func buildDefaultModelNudgeDataPayload(reqCtx *RequestContext) (map[string]any,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildUsableModelsPayload(reqCtx *RequestContext) (map[string]any, error) {
|
||||
adapters, err := loadConfiguredModelAdapters(reqCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"models": buildCLIModelDetails(adapters)}, nil
|
||||
}
|
||||
|
||||
func buildDefaultModelForCliPayload(reqCtx *RequestContext) (map[string]any, error) {
|
||||
adapters, err := loadConfiguredModelAdapters(reqCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models := buildCLIModelDetails(adapters)
|
||||
if len(models) == 0 {
|
||||
return map[string]any{"model": map[string]any{}}, nil
|
||||
}
|
||||
return map[string]any{"model": models[0]}, nil
|
||||
}
|
||||
|
||||
func buildDefaultModelPayload(reqCtx *RequestContext) (map[string]any, error) {
|
||||
adapters, err := loadConfiguredModelAdapters(reqCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaultModel := firstModelAdapterRef(adapters)
|
||||
return map[string]any{"model": defaultModel, "thinkingModel": defaultModel}, nil
|
||||
}
|
||||
|
||||
func buildBootstrapStatsigPayload(reqCtx *RequestContext) (map[string]any, error) {
|
||||
generatedAtMs := uint64(time.Now().UnixMilli())
|
||||
authID := resolveBootstrapStatsigAuthID(reqCtx)
|
||||
@@ -679,6 +720,25 @@ func buildAvailableModelEntries(adapters []legacyruntime.ModelAdapterConfig) []m
|
||||
return output
|
||||
}
|
||||
|
||||
func buildCLIModelDetails(adapters []legacyruntime.ModelAdapterConfig) []map[string]any {
|
||||
models := make([]map[string]any, 0, len(adapters))
|
||||
for _, adapter := range adapters {
|
||||
channelID := strings.TrimSpace(adapter.ID)
|
||||
if channelID == "" {
|
||||
continue
|
||||
}
|
||||
models = append(models, map[string]any{
|
||||
"modelId": channelID,
|
||||
"displayModelId": channelID,
|
||||
"apiKeyCredentials": map[string]any{
|
||||
"apiKey": strings.TrimSpace(adapter.APIKey),
|
||||
"baseUrl": strings.TrimSpace(adapter.BaseURL),
|
||||
},
|
||||
})
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func buildThinkingEffortParameterDefinitions(adapterType string) []map[string]any {
|
||||
values := thinkingEffortValuesForAdapter(adapterType)
|
||||
options := make([]map[string]any, 0, len(values))
|
||||
@@ -823,6 +883,16 @@ func collectModelAdapterRefs(adapters []legacyruntime.ModelAdapterConfig) []stri
|
||||
return output
|
||||
}
|
||||
|
||||
// firstModelAdapterRef возвращает канал первого адаптера или пустую строку,
|
||||
// если ни один адаптер не сконфигурирован.
|
||||
func firstModelAdapterRef(adapters []legacyruntime.ModelAdapterConfig) string {
|
||||
refs := collectModelAdapterRefs(adapters)
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return refs[0]
|
||||
}
|
||||
|
||||
func resolveBootstrapStatsigAuthID(reqCtx *RequestContext) string {
|
||||
if reqCtx != nil {
|
||||
if authID := authIDFromBearer(reqCtx.Headers.Get("authorization")); authID != "" {
|
||||
|
||||
@@ -2,9 +2,55 @@ package upstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestBuildCLIModelDetailsPreservesChannelCredentials(t *testing.T) {
|
||||
adapters := []legacyruntime.ModelAdapterConfig{
|
||||
{ID: " channel-a ", ModelID: "model-a", APIKey: "provider-secret-a", BaseURL: "https://provider-a.example/v1"},
|
||||
{ID: "channel-b", ModelID: "model-a"},
|
||||
{ID: "", ModelID: "model-c"},
|
||||
}
|
||||
|
||||
got := buildCLIModelDetails(adapters)
|
||||
want := []map[string]any{
|
||||
{"modelId": "channel-a", "displayModelId": "channel-a", "apiKeyCredentials": map[string]any{"apiKey": "provider-secret-a", "baseUrl": "https://provider-a.example/v1"}},
|
||||
{"modelId": "channel-b", "displayModelId": "channel-b", "apiKeyCredentials": map[string]any{"apiKey": "", "baseUrl": ""}},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("build CLI model details: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCLIModelsUsesAgentModelDetailsWireFormat(t *testing.T) {
|
||||
payload := map[string]any{"models": buildCLIModelDetails([]legacyruntime.ModelAdapterConfig{{ID: "channel-a", APIKey: "provider-secret", BaseURL: "https://provider.example/v1"}})}
|
||||
encoded, err := encodeMockProto("aiserver.v1.GetUsableModelsResponse", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode CLI models: %v", err)
|
||||
}
|
||||
|
||||
response := &agentv1.GetUsableModelsResponse{}
|
||||
if err := proto.Unmarshal(encoded, response); err != nil {
|
||||
t.Fatalf("decode CLI models with agent proto: %v", err)
|
||||
}
|
||||
if len(response.Models) != 1 {
|
||||
t.Fatalf("decoded model count: got %d, want 1", len(response.Models))
|
||||
}
|
||||
model := response.Models[0]
|
||||
if model.GetModelId() != "channel-a" || model.GetDisplayModelId() != "channel-a" {
|
||||
t.Fatalf("decoded channel IDs: model=%q display=%q", model.GetModelId(), model.GetDisplayModelId())
|
||||
}
|
||||
if credentials := model.GetApiKeyCredentials(); credentials == nil || credentials.GetApiKey() != "provider-secret" || credentials.GetBaseUrl() != "https://provider.example/v1" {
|
||||
t.Fatalf("decoded relay credentials: %#v", credentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBootstrapStatsigConfigJSONDisablesAlwaysLocalDecompositionGate(t *testing.T) {
|
||||
payload, err := buildBootstrapStatsigConfigJSON(12345, "test-auth-id")
|
||||
if err != nil {
|
||||
|
||||
@@ -24,6 +24,12 @@ type ModelAdapterTestResult = client.ModelAdapterTestResult
|
||||
// ModelAdapterTestResultsPayload 定义测速结果事件载荷。
|
||||
type ModelAdapterTestResultsPayload = client.ModelAdapterTestResultsPayload
|
||||
|
||||
// ModelAdapterModelsRequest 定义模型列表查询请求。
|
||||
type ModelAdapterModelsRequest = client.ModelAdapterModelsRequest
|
||||
|
||||
// ModelAdapterModelsResult 定义模型列表查询结果。
|
||||
type ModelAdapterModelsResult = client.ModelAdapterModelsResult
|
||||
|
||||
// CursorAccountStatus 是可安全展示给桌面前端的独立 Cursor 账号状态。
|
||||
type CursorAccountStatus = client.CursorAccountStatus
|
||||
|
||||
@@ -119,6 +125,11 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
|
||||
return s.core.GetModelAdapterTestResults()
|
||||
}
|
||||
|
||||
// FetchModelAdapterModels 用于从模型服务读取可用模型列表。
|
||||
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
|
||||
return s.core.FetchModelAdapterModels(input)
|
||||
}
|
||||
|
||||
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
|
||||
func (s *ProxyService) GetDeviceID() (string, error) {
|
||||
return s.core.GetDeviceID()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -31,8 +32,48 @@ const (
|
||||
modelAdapterTestDefaultMaxTokens = 65_536
|
||||
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
|
||||
modelAdapterTestMaxErrorBodyBytes = 8192
|
||||
modelAdapterListTimeout = 20 * time.Second
|
||||
modelAdapterListMaxBodyBytes = 8 << 20
|
||||
modelAdapterListPageSize = 1000
|
||||
modelAdapterListMaxPages = 50
|
||||
)
|
||||
|
||||
// modelListProviderRule 收敛各家模型列表接口的协议差异,避免判断散落到多个函数。
|
||||
type modelListProviderRule struct {
|
||||
// paths 按优先级排列,逐个尝试直到某个返回可用模型
|
||||
paths []string
|
||||
authHeader string
|
||||
authPrefix string
|
||||
extraHeader map[string]string
|
||||
// paginated 为真时按 limit + after_id 游标翻页,直到 has_more 为 false
|
||||
paginated bool
|
||||
}
|
||||
|
||||
var modelListProviderRules = map[string]modelListProviderRule{
|
||||
"openai": {
|
||||
paths: []string{"/models"},
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
},
|
||||
"anthropic": {
|
||||
paths: []string{"/models"},
|
||||
authHeader: "x-api-key",
|
||||
extraHeader: map[string]string{"anthropic-version": "2023-06-01"},
|
||||
paginated: true,
|
||||
},
|
||||
}
|
||||
|
||||
// modelListVersionSegments 用于判断 base url 是否已带版本前缀,带了就不再补 /v1。
|
||||
var modelListVersionSegments = map[string]bool{
|
||||
"v1": true,
|
||||
"v1beta": true,
|
||||
"v2": true,
|
||||
"beta": true,
|
||||
"openai": true,
|
||||
"compat": true,
|
||||
"compatible": true,
|
||||
}
|
||||
|
||||
type ModelAdapterTestStatus string
|
||||
|
||||
const (
|
||||
@@ -58,6 +99,20 @@ type ModelAdapterTestResult struct {
|
||||
TestedAt string `json:"testedAt"`
|
||||
}
|
||||
|
||||
// ModelAdapterModelsRequest 定义从兼容接口读取模型列表所需的最小配置。
|
||||
type ModelAdapterModelsRequest struct {
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
APIKey string `json:"apiKey"`
|
||||
CustomHeadersEnabled bool `json:"customHeadersEnabled"`
|
||||
CustomHeadersJSON string `json:"customHeadersJSON"`
|
||||
}
|
||||
|
||||
// ModelAdapterModelsResult 定义可供前端下拉选择的模型列表。
|
||||
type ModelAdapterModelsResult struct {
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
|
||||
type ModelAdapterTestResultsPayload struct {
|
||||
Results []ModelAdapterTestResult `json:"results"`
|
||||
@@ -108,6 +163,254 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
|
||||
return s.snapshotModelAdapterTestResults()
|
||||
}
|
||||
|
||||
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
|
||||
_ = s
|
||||
provider := strings.ToLower(strings.TrimSpace(input.Type))
|
||||
baseURL := strings.TrimSpace(input.BaseURL)
|
||||
apiKey := strings.TrimSpace(input.APIKey)
|
||||
rule, supported := modelListProviderRules[provider]
|
||||
if !supported {
|
||||
return ModelAdapterModelsResult{}, errors.New("模型类型仅支持 OpenAI 或 Anthropic")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return ModelAdapterModelsResult{}, errors.New("接口地址不能为空")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return ModelAdapterModelsResult{}, errors.New("访问密钥不能为空")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterListTimeout)
|
||||
defer cancel()
|
||||
|
||||
var lastErr error
|
||||
for _, endpoint := range buildModelListEndpointCandidates(rule, baseURL) {
|
||||
models, err := fetchModelListEndpoint(ctx, rule, endpoint, apiKey, input)
|
||||
if err == nil {
|
||||
return ModelAdapterModelsResult{Models: models}, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr != nil {
|
||||
return ModelAdapterModelsResult{}, lastErr
|
||||
}
|
||||
return ModelAdapterModelsResult{}, errors.New("未找到可用的模型列表接口")
|
||||
}
|
||||
|
||||
func buildModelListEndpointCandidates(rule modelListProviderRule, rawBaseURL string) []string {
|
||||
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
|
||||
for _, suffix := range []string{"/chat/completions", "/responses", "/messages"} {
|
||||
if strings.HasSuffix(strings.ToLower(base), suffix) {
|
||||
base = base[:len(base)-len(suffix)]
|
||||
}
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
|
||||
tail := strings.ToLower(base[strings.LastIndex(base, "/")+1:])
|
||||
var candidates []string
|
||||
switch {
|
||||
case tail == "models" || tail == "model":
|
||||
// 用户已经填到模型列表地址本身,直接用
|
||||
candidates = []string{base}
|
||||
case modelListVersionSegments[tail]:
|
||||
candidates = prefixModelListPaths(base, "", rule.paths)
|
||||
default:
|
||||
// base 没带版本段,优先试 /v1,再退回裸路径
|
||||
candidates = append(
|
||||
prefixModelListPaths(base, "/v1", rule.paths),
|
||||
prefixModelListPaths(base, "", rule.paths)...,
|
||||
)
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
endpoints := make([]string, 0, len(candidates))
|
||||
for _, endpoint := range candidates {
|
||||
if _, err := url.ParseRequestURI(endpoint); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[endpoint]; exists {
|
||||
continue
|
||||
}
|
||||
seen[endpoint] = struct{}{}
|
||||
endpoints = append(endpoints, endpoint)
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func prefixModelListPaths(base string, version string, paths []string) []string {
|
||||
endpoints := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
endpoints = append(endpoints, base+version+path)
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func fetchModelListEndpoint(ctx context.Context, rule modelListProviderRule, endpoint string, apiKey string, input ModelAdapterModelsRequest) ([]string, error) {
|
||||
collected := []string{}
|
||||
cursor := ""
|
||||
for page := 0; page < modelAdapterListMaxPages; page++ {
|
||||
requestURL := endpoint
|
||||
if rule.paginated {
|
||||
requestURL = appendModelListCursor(endpoint, cursor)
|
||||
}
|
||||
payload, err := requestModelListPayload(ctx, rule, requestURL, apiKey, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collected = append(collected, extractModelIDs(payload)...)
|
||||
if !rule.paginated {
|
||||
break
|
||||
}
|
||||
cursor = nextModelListCursor(payload)
|
||||
if cursor == "" {
|
||||
break
|
||||
}
|
||||
if page == modelAdapterListMaxPages-1 {
|
||||
return nil, fmt.Errorf("模型列表分页超过 %d 页,结果可能不完整", modelAdapterListMaxPages)
|
||||
}
|
||||
}
|
||||
|
||||
models := normalizeFetchedModelIDs(collected)
|
||||
if len(models) == 0 {
|
||||
return nil, errors.New("模型列表响应中没有可用模型")
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func requestModelListPayload(
|
||||
ctx context.Context,
|
||||
rule modelListProviderRule,
|
||||
requestURL string,
|
||||
apiKey string,
|
||||
input ModelAdapterModelsRequest,
|
||||
) (any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(rule.authHeader, rule.authPrefix+apiKey)
|
||||
for key, value := range rule.extraHeader {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
applyModelListCustomHeaders(req.Header, input)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, modelAdapterListMaxBodyBytes))
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
message := strings.TrimSpace(string(body))
|
||||
if len(message) > modelAdapterTestMaxErrorBodyBytes {
|
||||
message = message[:modelAdapterTestMaxErrorBodyBytes]
|
||||
}
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("读取模型列表失败:%s", message)
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("模型列表响应不是合法 JSON:%w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func appendModelListCursor(endpoint string, cursor string) string {
|
||||
query := url.Values{}
|
||||
query.Set("limit", strconv.Itoa(modelAdapterListPageSize))
|
||||
if cursor != "" {
|
||||
query.Set("after_id", cursor)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(endpoint, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return endpoint + separator + query.Encode()
|
||||
}
|
||||
|
||||
func nextModelListCursor(payload any) string {
|
||||
object, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if hasMore, _ := object["has_more"].(bool); !hasMore {
|
||||
return ""
|
||||
}
|
||||
cursor, _ := object["last_id"].(string)
|
||||
return strings.TrimSpace(cursor)
|
||||
}
|
||||
|
||||
func applyModelListCustomHeaders(header http.Header, input ModelAdapterModelsRequest) {
|
||||
if !input.CustomHeadersEnabled || strings.TrimSpace(input.CustomHeadersJSON) == "" {
|
||||
return
|
||||
}
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal([]byte(input.CustomHeadersJSON), &parsed); err != nil {
|
||||
return
|
||||
}
|
||||
for key, value := range parsed {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func extractModelIDs(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{typed}
|
||||
case []any:
|
||||
models := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
models = append(models, extractModelIDs(item)...)
|
||||
}
|
||||
return models
|
||||
case map[string]any:
|
||||
for _, key := range []string{"id", "name"} {
|
||||
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return []string{text}
|
||||
}
|
||||
}
|
||||
models := []string{}
|
||||
for _, key := range []string{"data", "models"} {
|
||||
if child, ok := typed[key]; ok {
|
||||
models = append(models, extractModelIDs(child)...)
|
||||
}
|
||||
}
|
||||
return models
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFetchedModelIDs(input []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
models := make([]string, 0, len(input))
|
||||
for _, item := range input {
|
||||
model := strings.TrimSpace(item)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[model]; exists {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
models = append(models, model)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models
|
||||
}
|
||||
|
||||
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
|
||||
requestHash := buildModelAdapterTestRequestHash(adapter)
|
||||
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildModelListEndpointCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
baseURL string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "openai 带版本段不再补 v1",
|
||||
provider: "openai",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
want: []string{"https://api.openai.com/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "openai 裸域名优先试 v1",
|
||||
provider: "openai",
|
||||
baseURL: "https://api.openai.com",
|
||||
want: []string{"https://api.openai.com/v1/models", "https://api.openai.com/models"},
|
||||
},
|
||||
{
|
||||
name: "anthropic 裸域名优先试 v1",
|
||||
provider: "anthropic",
|
||||
baseURL: "https://api.anthropic.com",
|
||||
want: []string{"https://api.anthropic.com/v1/models", "https://api.anthropic.com/models"},
|
||||
},
|
||||
{
|
||||
name: "anthropic 带版本段不再补 v1",
|
||||
provider: "anthropic",
|
||||
baseURL: "https://api.anthropic.com/v1",
|
||||
want: []string{"https://api.anthropic.com/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "剥离 chat completions 后缀",
|
||||
provider: "openai",
|
||||
baseURL: "https://api.example.com/v1/chat/completions",
|
||||
want: []string{"https://api.example.com/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "剥离 responses 后缀",
|
||||
provider: "openai",
|
||||
baseURL: "https://api.example.com/v1/responses",
|
||||
want: []string{"https://api.example.com/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "剥离 anthropic messages 后缀",
|
||||
provider: "anthropic",
|
||||
baseURL: "https://api.example.com/v1/messages",
|
||||
want: []string{"https://api.example.com/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "已填到 models 地址本身则原样使用",
|
||||
provider: "openai",
|
||||
baseURL: "https://api.example.com/openai/v1/models",
|
||||
want: []string{"https://api.example.com/openai/v1/models"},
|
||||
},
|
||||
{
|
||||
name: "自定义网关前缀会补 v1",
|
||||
provider: "openai",
|
||||
baseURL: "https://gateway.example.com/proxy",
|
||||
want: []string{
|
||||
"https://gateway.example.com/proxy/v1/models",
|
||||
"https://gateway.example.com/proxy/models",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "尾部斜杠不影响推导",
|
||||
provider: "anthropic",
|
||||
baseURL: " https://api.anthropic.com/v1/ ",
|
||||
want: []string{"https://api.anthropic.com/v1/models"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rule, ok := modelListProviderRules[test.provider]
|
||||
if !ok {
|
||||
t.Fatalf("provider %q 没有对应规则", test.provider)
|
||||
}
|
||||
got := buildModelListEndpointCandidates(rule, test.baseURL)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("buildModelListEndpointCandidates(%q) = %v, want %v", test.baseURL, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsOpenAIUsesBearer(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotAuth string
|
||||
var gotAnthropicVersion string
|
||||
var gotAPIKeyHeader string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotAnthropicVersion = r.Header.Get("anthropic-version")
|
||||
gotAPIKeyHeader = r.Header.Get("x-api-key")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"},{"id":"gpt-4o"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "openai",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
|
||||
if gotPath != "/v1/models" {
|
||||
t.Fatalf("请求路径 = %q, want /v1/models", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer sk-test" {
|
||||
t.Fatalf("Authorization = %q, want Bearer sk-test", gotAuth)
|
||||
}
|
||||
if gotAPIKeyHeader != "" {
|
||||
t.Fatalf("openai 不应发送 x-api-key,实际 = %q", gotAPIKeyHeader)
|
||||
}
|
||||
if gotAnthropicVersion != "" {
|
||||
t.Fatalf("openai 不应发送 anthropic-version,实际 = %q", gotAnthropicVersion)
|
||||
}
|
||||
want := []string{"gpt-4o", "gpt-5"}
|
||||
if !reflect.DeepEqual(result.Models, want) {
|
||||
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsAnthropicUsesAPIKeyHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
var gotAPIKeyHeader string
|
||||
var gotAnthropicVersion string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotAPIKeyHeader = r.Header.Get("x-api-key")
|
||||
gotAnthropicVersion = r.Header.Get("anthropic-version")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"claude-sonnet-4"}],"has_more":false}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "anthropic",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-ant-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
|
||||
if gotAPIKeyHeader != "sk-ant-test" {
|
||||
t.Fatalf("x-api-key = %q, want sk-ant-test", gotAPIKeyHeader)
|
||||
}
|
||||
if gotAnthropicVersion != "2023-06-01" {
|
||||
t.Fatalf("anthropic-version = %q, want 2023-06-01", gotAnthropicVersion)
|
||||
}
|
||||
if gotAuth != "" {
|
||||
t.Fatalf("anthropic 不应发送 Authorization,实际 = %q", gotAuth)
|
||||
}
|
||||
want := []string{"claude-sonnet-4"}
|
||||
if !reflect.DeepEqual(result.Models, want) {
|
||||
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsAnthropicFollowsCursor(t *testing.T) {
|
||||
var requestedQueries []string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedQueries = append(requestedQueries, r.URL.RawQuery)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Query().Get("after_id") {
|
||||
case "":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"claude-a"}],"has_more":true,"last_id":"claude-a"}`))
|
||||
case "claude-a":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"claude-b"}],"has_more":true,"last_id":"claude-b"}`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"claude-c"}],"has_more":false}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "anthropic",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-ant-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
|
||||
want := []string{"claude-a", "claude-b", "claude-c"}
|
||||
if !reflect.DeepEqual(result.Models, want) {
|
||||
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||
}
|
||||
if len(requestedQueries) != 3 {
|
||||
t.Fatalf("请求次数 = %d, want 3(两次翻页后停止)", len(requestedQueries))
|
||||
}
|
||||
for _, query := range requestedQueries {
|
||||
if !strings.Contains(query, "limit=1000") {
|
||||
t.Fatalf("翻页请求缺少 limit 参数:%q", query)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(requestedQueries[1], "after_id=claude-a") {
|
||||
t.Fatalf("第二页未带上游标:%q", requestedQueries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsOpenAIDoesNotPaginate(t *testing.T) {
|
||||
requestCount := 0
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if r.URL.RawQuery != "" {
|
||||
t.Errorf("openai 不应附加分页参数,实际 = %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5"}],"has_more":true,"last_id":"gpt-5"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
if _, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "openai",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("请求次数 = %d, want 1(openai 忽略 has_more)", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsReadsLargeBody(t *testing.T) {
|
||||
models := make([]map[string]string, 0, 400)
|
||||
for index := 0; index < 400; index++ {
|
||||
models = append(models, map[string]string{
|
||||
"id": "vendor/model-with-a-fairly-long-identifier-" + strings.Repeat("x", 40) + "-" + string(rune('a'+index%26)) + strconv.Itoa(index),
|
||||
})
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"data": models})
|
||||
if err != nil {
|
||||
t.Fatalf("构造响应失败:%v", err)
|
||||
}
|
||||
if len(body) <= modelAdapterTestMaxErrorBodyBytes {
|
||||
t.Fatalf("测试响应体只有 %d 字节,需要大于 %d 才能覆盖截断场景", len(body), modelAdapterTestMaxErrorBodyBytes)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "openai",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
if len(result.Models) != len(models) {
|
||||
t.Fatalf("Models 数量 = %d, want %d", len(result.Models), len(models))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsSupportsStringItems(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":["gpt-4o","gpt-4.1"]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
result, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "openai",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchModelAdapterModels 返回错误:%v", err)
|
||||
}
|
||||
want := []string{"gpt-4.1", "gpt-4o"}
|
||||
if !reflect.DeepEqual(result.Models, want) {
|
||||
t.Fatalf("Models = %v, want %v", result.Models, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsRejectsPaginationTruncation(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"claude-model"}],"has_more":true,"last_id":"next"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := &ProxyService{}
|
||||
_, err := service.FetchModelAdapterModels(ModelAdapterModelsRequest{
|
||||
Type: "anthropic",
|
||||
BaseURL: server.URL + "/v1",
|
||||
APIKey: "sk-ant-test",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "结果可能不完整") {
|
||||
t.Fatalf("期望分页截断错误,实际 = %v", err)
|
||||
}
|
||||
if requestCount != modelAdapterListMaxPages {
|
||||
t.Fatalf("请求次数 = %d, want %d", requestCount, modelAdapterListMaxPages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchModelAdapterModelsRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request ModelAdapterModelsRequest
|
||||
}{
|
||||
{name: "未知类型", request: ModelAdapterModelsRequest{Type: "gemini", BaseURL: "https://x.com", APIKey: "k"}},
|
||||
{name: "缺少地址", request: ModelAdapterModelsRequest{Type: "openai", APIKey: "k"}},
|
||||
{name: "缺少密钥", request: ModelAdapterModelsRequest{Type: "openai", BaseURL: "https://x.com"}},
|
||||
}
|
||||
|
||||
service := &ProxyService{}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := service.FetchModelAdapterModels(test.request); err == nil {
|
||||
t.Fatal("期望返回错误,实际为 nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2500
-739
File diff suppressed because it is too large
Load Diff
+10930
-4348
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cursor-proto-sync.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for PROTO_NAME in agent_v1.proto aiserver_v1.proto; do
|
||||
ROOT_PROTO="$SCRIPT_DIR/$PROTO_NAME"
|
||||
EXTRACTED_PROTO="$SCRIPT_DIR/from_extensions/$PROTO_NAME"
|
||||
if [[ ! -f "$ROOT_PROTO" || ! -f "$EXTRACTED_PROTO" ]]; then
|
||||
echo "Missing proto pair for $PROTO_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$ROOT_PROTO" > "$TEMP_DIR/root-$PROTO_NAME"
|
||||
sed -E 's|^option go_package = ".*";$|option go_package = "__NORMALIZED__";|' "$EXTRACTED_PROTO" > "$TEMP_DIR/extracted-$PROTO_NAME"
|
||||
if ! cmp -s "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME"; then
|
||||
echo "Proto snapshot is out of sync: $PROTO_NAME" >&2
|
||||
diff -u "$TEMP_DIR/root-$PROTO_NAME" "$TEMP_DIR/extracted-$PROTO_NAME" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
+226
-60
@@ -101,24 +101,28 @@ func SetStrictMode(enabled bool) {
|
||||
var activeDiagnostics *extractionDiagnostics
|
||||
|
||||
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$.]+)`)
|
||||
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*(?:[,;])`)
|
||||
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*;`)
|
||||
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*(?:[,;])`)
|
||||
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*\)`)
|
||||
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*;`)
|
||||
)
|
||||
|
||||
type Field struct {
|
||||
@@ -185,13 +189,18 @@ type symbolDef struct {
|
||||
}
|
||||
|
||||
type TypeResolver struct {
|
||||
bySymbol map[string][]symbolDef
|
||||
byShort map[string][]symbolDef
|
||||
bySymbol map[string][]symbolDef
|
||||
byAlias map[string][]symbolDef
|
||||
byShort map[string][]symbolDef
|
||||
moduleImports map[int]map[string]int
|
||||
}
|
||||
|
||||
func newTypeResolver(messages []Message, enums []Enum, aliases map[string][]string) *TypeResolver {
|
||||
type aliasIndex map[int]map[string][]string
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -218,43 +227,64 @@ func newTypeResolver(messages []Message, enums []Enum, aliases map[string][]stri
|
||||
}
|
||||
}
|
||||
}
|
||||
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.VarName, msg.InternalName) {
|
||||
add(alias, 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.VarName) {
|
||||
add(alias, 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
|
||||
}
|
||||
|
||||
func buildAliasIndex(text string) map[string][]string {
|
||||
matches := varAliasRe.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
direct := make(map[string]string, len(matches))
|
||||
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
|
||||
matches := varAliasRe.FindAllStringSubmatchIndex(text, -1)
|
||||
directByModule := make(map[int]map[string]string)
|
||||
for _, match := range matches {
|
||||
alias := strings.TrimSpace(match[1])
|
||||
target := strings.TrimSpace(match[2])
|
||||
alias := strings.TrimSpace(text[match[2]:match[3]])
|
||||
target := strings.TrimSpace(text[match[4]:match[5]])
|
||||
if alias == "" || target == "" || alias == target {
|
||||
continue
|
||||
}
|
||||
direct[alias] = target
|
||||
moduleStart := moduleStartForPos(moduleStarts, match[0])
|
||||
if directByModule[moduleStart] == nil {
|
||||
directByModule[moduleStart] = make(map[string]string)
|
||||
}
|
||||
directByModule[moduleStart][alias] = target
|
||||
}
|
||||
|
||||
resolveRoot := func(symbol string) string {
|
||||
resolveRoot := func(direct map[string]string, symbol string) string {
|
||||
seen := make(map[string]bool)
|
||||
current := symbol
|
||||
for {
|
||||
@@ -270,16 +300,90 @@ func buildAliasIndex(text string) map[string][]string {
|
||||
}
|
||||
}
|
||||
|
||||
aliases := make(map[string][]string)
|
||||
for alias := range direct {
|
||||
root := resolveRoot(alias)
|
||||
if root == alias {
|
||||
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
|
||||
}
|
||||
|
||||
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 exposes module members through tables such as
|
||||
// n.d(t, { KS: () => T }). Service descriptors refer to the exported
|
||||
// name (r.KS), while message definitions use the local symbol (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
|
||||
}
|
||||
aliases[root] = append(aliases[root], alias)
|
||||
block := text[blockStart:blockEnd]
|
||||
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
|
||||
addAlias(moduleStart, entry[2], entry[1])
|
||||
}
|
||||
}
|
||||
for root := range aliases {
|
||||
sort.Strings(aliases[root])
|
||||
|
||||
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
|
||||
}
|
||||
@@ -318,11 +422,10 @@ func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleSta
|
||||
}
|
||||
|
||||
filtered := candidates
|
||||
if strings.TrimSpace(preferredPkg) != "" {
|
||||
if strings.TrimSpace(expectedKind) != "" {
|
||||
tmp := make([]symbolDef, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
pkg, _ := parseTypeName(item.TypeName)
|
||||
if pkg == preferredPkg {
|
||||
if item.Kind == expectedKind {
|
||||
tmp = append(tmp, item)
|
||||
}
|
||||
}
|
||||
@@ -331,10 +434,11 @@ func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleSta
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(expectedKind) != "" {
|
||||
if strings.TrimSpace(preferredPkg) != "" {
|
||||
tmp := make([]symbolDef, 0, len(filtered))
|
||||
for _, item := range filtered {
|
||||
if item.Kind == expectedKind {
|
||||
pkg, _ := parseTypeName(item.TypeName)
|
||||
if pkg == preferredPkg {
|
||||
tmp = append(tmp, item)
|
||||
}
|
||||
}
|
||||
@@ -416,6 +520,17 @@ func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contex
|
||||
}
|
||||
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 {
|
||||
@@ -435,17 +550,30 @@ func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contex
|
||||
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
|
||||
}
|
||||
first := parts[0]
|
||||
if typeName, ok := resolveBySymbol(first, false); ok {
|
||||
return typeName, true
|
||||
}
|
||||
@@ -473,7 +601,7 @@ func absInt(value int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
var moduleStartRe = regexp.MustCompile(`(?:^|,)(\d+):(?:function\([\w$,]*\)|\([\w$,]*\)=>)\{`)
|
||||
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
|
||||
|
||||
func buildModuleStarts(text string) []int {
|
||||
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
||||
@@ -497,6 +625,38 @@ func moduleStartForPos(moduleStarts []int, pos int) int {
|
||||
return moduleStarts[index]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ExtractProtos extracts proto definitions from formatted JS file
|
||||
func ExtractProtos(inputFile, outputDir string) {
|
||||
activeDiagnostics = newExtractionDiagnostics()
|
||||
@@ -512,7 +672,8 @@ func ExtractProtos(inputFile, outputDir string) {
|
||||
|
||||
text := string(content)
|
||||
moduleStarts := buildModuleStarts(text)
|
||||
aliases := buildAliasIndex(text)
|
||||
aliases := buildAliasIndex(text, moduleStarts)
|
||||
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
|
||||
|
||||
// Extract messages, enums, and services
|
||||
messages := extractMessages(text, moduleStarts)
|
||||
@@ -524,7 +685,8 @@ func ExtractProtos(inputFile, outputDir string) {
|
||||
}
|
||||
}
|
||||
|
||||
resolver := newTypeResolver(messages, enums, aliases)
|
||||
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
|
||||
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
|
||||
|
||||
// Generate proto files
|
||||
generateProtos(messages, enums, services, resolver, outputDir)
|
||||
@@ -900,6 +1062,8 @@ func parseFieldObject(obj string) (*Field, error) {
|
||||
} else {
|
||||
field.T = tMatch[1]
|
||||
}
|
||||
} else if shorthandTRe.MatchString(obj) {
|
||||
field.T = "T"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,8 +1359,9 @@ func copyAllExternalTypes(pkgName string, pkg struct {
|
||||
neededTypes := make(map[string]bool)
|
||||
|
||||
for _, msg := range result.messages {
|
||||
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||
for _, f := range msg.Fields {
|
||||
collectFieldRefsSimple(f, pkgName, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
||||
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
||||
}
|
||||
}
|
||||
for _, svc := range result.services {
|
||||
@@ -1268,7 +1433,7 @@ func copyAllExternalTypes(pkgName string, pkg struct {
|
||||
}
|
||||
|
||||
// collectFieldRefsSimple collects external type references from a field (non-recursive, just this field)
|
||||
func collectFieldRefsSimple(f Field, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||
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 {
|
||||
@@ -1289,7 +1454,7 @@ func collectFieldRefsSimple(f Field, currentPkg string, contextPos int, contextM
|
||||
}
|
||||
|
||||
for _, item := range refs {
|
||||
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, currentPkg, item.kind)
|
||||
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -1637,6 +1802,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
||||
// Get the current message's path prefix for relative type resolution
|
||||
msgPath := msg.ShortName
|
||||
currentPkg := msg.Package
|
||||
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||
|
||||
// Group fields by oneof
|
||||
oneofGroups := make(map[string][]Field)
|
||||
@@ -1652,7 +1818,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
||||
|
||||
// Write regular fields
|
||||
for _, f := range regularFields {
|
||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart)
|
||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
|
||||
prefix := ""
|
||||
if f.Repeated {
|
||||
prefix = "repeated "
|
||||
@@ -1673,7 +1839,7 @@ func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolve
|
||||
fields := oneofGroups[oneofName]
|
||||
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
|
||||
for _, f := range fields {
|
||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart)
|
||||
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))
|
||||
@@ -1721,15 +1887,15 @@ func getNestedPath(shortName string) []string {
|
||||
}
|
||||
|
||||
func resolveFieldType(f Field, resolver *TypeResolver, contextPos int, contextModuleStart int) string {
|
||||
return resolveFieldTypeWithPkg(f, resolver, "", "", contextPos, contextModuleStart)
|
||||
return resolveFieldTypeWithPkg(f, resolver, "", "", "", contextPos, contextModuleStart)
|
||||
}
|
||||
|
||||
// resolveFieldTypeWithPkg resolves field type with package awareness
|
||||
// parentPath is like "ConversationMessage" or "ConversationMessage.ToolResult"
|
||||
// currentPkg is the package of the current message being written (e.g., "agent.v1")
|
||||
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, contextPos int, contextModuleStart int) string {
|
||||
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, currentPkg, expectedKind)
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
|
||||
if !ok {
|
||||
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
|
||||
return fallbackTypeToken(ref)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
src/proto/**
|
||||
media/index.js
|
||||
@@ -1 +0,0 @@
|
||||
{"name":"cursor-always-local","description":"Implements experimentation features for Cursor","author":"Anysphere, Inc.","publisher":"anysphere","version":"0.0.1","private":true,"repository":{"type":"git","url":"https://github.com/anysphere/vscode"},"extensionKind":["ui"],"engines":{"vscode":"^1.43.0","yarn":"please-use-npm"},"activationEvents":["onStartupFinished","onResolveRemoteAuthority:background-composer"],"enabledApiProposals":["cursor","control","externalUriOpener","contribSourceControlInputBoxMenu"],"main":"./dist/main","contributes":{"commands":[],"keybindings":[],"menus":{"scm/inputBox":[{"command":"cursor.generateGitCommitMessage","when":"scmProvider == git"}]},"jsonValidation":[{"fileMatch":".cursor/environment.json","url":"./schemas/environment.schema.json"}],"configuration":{"type":"object","title":"Cursor Always Local"}},"optionalDependencies":{"@vscode/windows-ca-certs":"^0.3.3"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"displayName":"Cursor Always Local","description":"Experimentation @ cursor.sh"}
|
||||
@@ -1 +0,0 @@
|
||||
{"$schema":"https://json-schema.org/draft/2019-09/schema","description":"Defines a dev environment","allowComments":true,"allowTrailingCommas":false,"definitions":{"common":{"type":"object","properties":{"name":{"type":"string","description":"The name of the environment."},"user":{"type":"string","description":"The user to run the environment as."},"install":{"type":"string","description":"The update command to run on VM startup (after pulling latest changes) to refresh dependencies."},"start":{"type":"string","description":"The start command to run when the environment is started."},"repositoryDependencies":{"type":"array","description":"Repositories that are required for the environment to work, and need to be included in the GitHub access token that is generated for the environment.","items":{"type":"string","description":"The URL of the dependent repository, e.g. `github.com/org/repo`."}},"ports":{"type":"array","description":"Ports to expose from the container. Similar to devcontainers port forwarding.","items":{"type":"object","required":["port"],"properties":{"name":{"type":"string","description":"A descriptive name for the port (e.g., 'web server', 'api')."},"port":{"type":"integer","minimum":1,"maximum":65535,"description":"The port number inside the container to expose."}}}},"terminals":{"type":"array","description":"The terminals to run when the environment is started.","items":{"oneOf":[{"type":"array","items":{"type":"object","required":["command"],"properties":{"name":{"type":"string","description":"The name of the terminal."},"command":{"type":"string","description":"The command to run in the terminal."},"description":{"type":"string","description":"A description of what the terminal does. This is displayed to the agent."}}}},{"type":"object","required":["command"],"properties":{"name":{"type":"string","description":"The name of the terminal."},"command":{"type":"string","description":"The command to run in the terminal."},"description":{"type":"string","description":"A description of what the terminal does. This is displayed to the agent."}}}]}}}},"container":{"type":"object","properties":{"build":{"type":"object","description":"Docker build-related options.","properties":{"dockerfile":{"type":"string","description":"The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `environment.json` file."},"context":{"type":"string","description":"The location of the context folder for building the Docker image. The path is relative to the folder containing the `environment.json` file."}},"required":["dockerfile"],"unevaluatedProperties":false},"snapshot":{"type":"string","description":"A snapshot ID for the base environment."},"agentCanUpdateSnapshot":{"type":"boolean","description":"Whether the agent can update the snapshot."}},"required":[]}},"allOf":[{"$ref":"#/definitions/container"},{"$ref":"#/definitions/common"}],"unevaluatedProperties":false}
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run vitest with any additional arguments passed after --
|
||||
npx vitest run --config vitest.config.ts "$@"
|
||||
|
||||
# Capture the exit code
|
||||
exit_code=$?
|
||||
|
||||
# If tests failed, echo the colored error message
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
echo -e "\033[1;31m❌ Test failed -- you can set CURSOR_EXT_TEST_LOG_LEVEL=info to see more logs\033[0m"
|
||||
fi
|
||||
|
||||
# Exit with the same code as vitest
|
||||
exit $exit_code
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/bash
|
||||
include_files=()
|
||||
for f in "$@"; do
|
||||
include_files+=("${f#$PWD/}")
|
||||
done
|
||||
node_modules/.bin/tsc --noEmit -p . | (
|
||||
status=0
|
||||
show_continuation=false
|
||||
while IFS='' read -r line; do
|
||||
case "$line" in
|
||||
(' '*)
|
||||
if $show_continuation; then
|
||||
echo "$line" >&2
|
||||
fi
|
||||
;;
|
||||
(*)
|
||||
file="${line%%(*}"
|
||||
if [[ " ${include_files[@]} " =~ " ${file} " ]]; then
|
||||
show_continuation=true
|
||||
echo "$line" >&2
|
||||
status=1
|
||||
else
|
||||
show_continuation=false
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit $status
|
||||
)
|
||||
@@ -2,27 +2,30 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SNAPSHOT_DEFAULT="$SCRIPT_DIR/extensions-cursor-app/cursor-always-local"
|
||||
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
LATEST_EXT_DIR="$(
|
||||
find "$SCRIPT_DIR" -maxdepth 1 -type d -name 'extensions-*' 2>/dev/null \
|
||||
| sort -V \
|
||||
| tail -n 1
|
||||
)"
|
||||
if [[ -f "$SNAPSHOT_DEFAULT/dist/main.js" ]]; then
|
||||
INPUT_DEFAULT="$SNAPSHOT_DEFAULT"
|
||||
elif [[ -f "$INSTALLED_CURSOR_DEFAULT" ]]; then
|
||||
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
|
||||
elif [[ -n "$LATEST_EXT_DIR" ]]; then
|
||||
INPUT_DEFAULT="$LATEST_EXT_DIR"
|
||||
else
|
||||
INPUT_DEFAULT="$SCRIPT_DIR/extensions-2.6.19"
|
||||
fi
|
||||
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
|
||||
OUTPUT_DEFAULT="$SCRIPT_DIR/from_extensions"
|
||||
|
||||
INPUT_PATH="${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"
|
||||
}
|
||||
|
||||
# Resolve input: accept either a single JS file or an extensions root directory.
|
||||
if [[ -d "$INPUT_PATH" ]]; then
|
||||
CANDIDATES=(
|
||||
@@ -40,7 +43,10 @@ if [[ -d "$INPUT_PATH" ]]; then
|
||||
if [[ -n "$FOUND_CANDIDATE" ]]; then
|
||||
INPUT_PATH="$FOUND_CANDIDATE"
|
||||
else
|
||||
mapfile -t JS_FILES < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
||||
JS_FILES=()
|
||||
while IFS= read -r JS_FILE; do
|
||||
JS_FILES+=("$JS_FILE")
|
||||
done < <(find "$INPUT_PATH" -type f -path "*/dist/main.js" | sort)
|
||||
if [[ ${#JS_FILES[@]} -eq 1 ]]; then
|
||||
INPUT_PATH="${JS_FILES[0]}"
|
||||
elif [[ ${#JS_FILES[@]} -eq 0 ]]; then
|
||||
@@ -57,15 +63,62 @@ fi
|
||||
|
||||
if [[ ! -f "$INPUT_PATH" ]]; then
|
||||
echo "Input JS not found: $INPUT_PATH" >&2
|
||||
echo "Usage: $0 [input-js-file-or-extensions-dir] [output-dir]" >&2
|
||||
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
|
||||
echo " $0 /path/to/cursor-always-local/dist/main.js [output-dir]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
INPUT_PATH="$(canonicalize_path "$INPUT_PATH")"
|
||||
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
|
||||
case "$OUTPUT_DIR" in
|
||||
"/"|"$HOME"|"$REPO_ROOT"|"$SCRIPT_DIR"|"$CURRENT_DIR")
|
||||
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$INPUT_PATH" in
|
||||
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
|
||||
echo "Refusing output directory that contains the input bundle: $OUTPUT_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
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
|
||||
|
||||
go run "$SCRIPT_DIR/ext_tool" \
|
||||
-input "$INPUT_PATH" \
|
||||
-output "$OUTPUT_DIR" \
|
||||
-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
|
||||
|
||||
+2500
-739
File diff suppressed because it is too large
Load Diff
+10930
-4348
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,809 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package git_forge.v1;
|
||||
|
||||
option go_package = "react-admin/cursor-server/gen/git_forge/v1;git_forgev1";
|
||||
|
||||
// Copied from: local:git_forge.v1.BatchGetRepoContentRequest (var: Qt)
|
||||
message BatchGetRepoContentRequest {
|
||||
string repo_uuid = 1;
|
||||
string revision = 2;
|
||||
repeated string paths = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.BatchGetRepoContentResponse (var: Ht)
|
||||
message BatchGetRepoContentResponse {
|
||||
repeated BatchRepoContentResult results = 1;
|
||||
string resolved_commit_sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.BatchRepoContentResult (var: Gt)
|
||||
message BatchRepoContentResult {
|
||||
string path = 1;
|
||||
bool found = 2;
|
||||
oneof content {
|
||||
FileContent file_content = 3;
|
||||
DirectoryContent directory_content = 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.BlameChunk (var: sn)
|
||||
message BlameChunk {
|
||||
repeated ShortCommit commits = 1;
|
||||
repeated BlameLineRange line_ranges = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.BlameLineRange (var: rn)
|
||||
message BlameLineRange {
|
||||
bytes commit_sha = 1;
|
||||
uint32 start_in_blamed_file = 2;
|
||||
uint32 len = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CanMergeRequest (var: ft)
|
||||
message CanMergeRequest {
|
||||
string repo_uuid = 1;
|
||||
string ours = 2;
|
||||
string theirs = 3;
|
||||
uint64 change_number = 4;
|
||||
MergeMode mode = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CanMergeResponse (var: gt)
|
||||
message CanMergeResponse {
|
||||
bool can_merge_without_conflicts = 1;
|
||||
optional bytes merged_tree_sha = 2;
|
||||
repeated string conflicted_paths = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ChangeKind (var: _)
|
||||
enum ChangeKind {
|
||||
CHANGE_KIND_UNSPECIFIED = 0;
|
||||
CHANGE_KIND_ADDED = 1;
|
||||
CHANGE_KIND_DELETED = 2;
|
||||
CHANGE_KIND_MODIFIED = 3;
|
||||
CHANGE_KIND_RENAMED = 4;
|
||||
CHANGE_KIND_COPIED = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ChangedFile (var: Nn)
|
||||
message ChangedFile {
|
||||
string path = 1;
|
||||
optional string old_path = 2;
|
||||
ChangeKind change_kind = 3;
|
||||
optional FileMode old_mode = 4;
|
||||
optional FileMode new_mode = 5;
|
||||
optional string old_sha = 6;
|
||||
optional string new_sha = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ChangedFileWithStats (var: Pn)
|
||||
message ChangedFileWithStats {
|
||||
string path = 1;
|
||||
optional string old_path = 2;
|
||||
ChangeKind change_kind = 3;
|
||||
bool is_binary = 4;
|
||||
int32 additions = 5;
|
||||
int32 deletions = 6;
|
||||
optional FileMode old_mode = 7;
|
||||
optional FileMode new_mode = 8;
|
||||
optional string old_sha = 9;
|
||||
optional string new_sha = 10;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.Commit (var: bt)
|
||||
message Commit {
|
||||
string sha = 1;
|
||||
string message = 2;
|
||||
Signature author = 3;
|
||||
Signature committer = 4;
|
||||
repeated string parent_shas = 5;
|
||||
string tree_sha = 7;
|
||||
optional string change_id = 8;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitDiffChunk (var: Jn)
|
||||
message CommitDiffChunk {
|
||||
optional CommitDiffHeader header = 1;
|
||||
repeated FileDiff file_diffs = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitDiffHeader (var: Bn)
|
||||
message CommitDiffHeader {
|
||||
Commit commit = 1;
|
||||
optional string base_commit_sha = 2;
|
||||
CommitDiffStats stats = 3;
|
||||
repeated ChangedFileWithStats changed_files = 4;
|
||||
bool has_more = 5;
|
||||
optional string next_page_cursor = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitDiffStats (var: Rn)
|
||||
message CommitDiffStats {
|
||||
uint32 files_changed = 1;
|
||||
int32 additions = 2;
|
||||
int32 deletions = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitFileDelete (var: Ct)
|
||||
message CommitFileDelete {
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitFileMode (var: g)
|
||||
enum CommitFileMode {
|
||||
COMMIT_FILE_MODE_UNSPECIFIED = 0;
|
||||
COMMIT_FILE_MODE_REGULAR = 1;
|
||||
COMMIT_FILE_MODE_EXECUTABLE = 2;
|
||||
COMMIT_FILE_MODE_SYMLINK = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitFileOperation (var: Nt)
|
||||
message CommitFileOperation {
|
||||
string path = 1;
|
||||
oneof operation {
|
||||
CommitFileUpsert upsert = 2;
|
||||
CommitFileDelete delete = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CommitFileUpsert (var: Jt)
|
||||
message CommitFileUpsert {
|
||||
bytes content = 1;
|
||||
CommitFileMode mode = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CompareCommitsRequest (var: dt)
|
||||
message CompareCommitsRequest {
|
||||
string repo_uuid = 1;
|
||||
string base_revision = 2;
|
||||
string head_revision = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CompareCommitsResponse (var: pt)
|
||||
message CompareCommitsResponse {
|
||||
CompareCommitsStatus status = 1;
|
||||
int32 ahead_by = 2;
|
||||
int32 behind_by = 3;
|
||||
string base_commit_sha = 4;
|
||||
string head_commit_sha = 5;
|
||||
string merge_base_commit_sha = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CompareCommitsStatus (var: p)
|
||||
enum CompareCommitsStatus {
|
||||
COMPARE_COMMITS_STATUS_UNSPECIFIED = 0;
|
||||
COMPARE_COMMITS_STATUS_IDENTICAL = 1;
|
||||
COMPARE_COMMITS_STATUS_AHEAD = 2;
|
||||
COMPARE_COMMITS_STATUS_BEHIND = 3;
|
||||
COMPARE_COMMITS_STATUS_DIVERGED = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ComputeMergeCommitRequest (var: _t)
|
||||
message ComputeMergeCommitRequest {
|
||||
string repo_uuid = 1;
|
||||
string ours_sha = 2;
|
||||
string theirs_sha = 3;
|
||||
string message = 4;
|
||||
Signature author = 5;
|
||||
Signature committer = 6;
|
||||
MergeMode mode = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ComputeMergeCommitResponse (var: Tt)
|
||||
message ComputeMergeCommitResponse {
|
||||
string merge_commit_sha = 1;
|
||||
bytes packfile = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateCommitFromFilesRequest (var: Rt)
|
||||
message CreateCommitFromFilesRequest {
|
||||
string repo_uuid = 1;
|
||||
string target_ref = 2;
|
||||
optional string expected_head_sha = 3;
|
||||
string message = 4;
|
||||
Signature author = 5;
|
||||
optional Signature committer = 6;
|
||||
repeated CommitFileOperation files = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateCommitFromFilesResponse (var: Pt)
|
||||
message CreateCommitFromFilesResponse {
|
||||
string commit_sha = 1;
|
||||
string tree_sha = 2;
|
||||
string old_head_sha = 3;
|
||||
string wal_entry_key = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateMergeCommitRequest (var: ht)
|
||||
message CreateMergeCommitRequest {
|
||||
string repo_uuid = 1;
|
||||
optional string ours_sha = 2;
|
||||
optional string theirs_sha = 3;
|
||||
string ours_ref = 4;
|
||||
string theirs_ref = 5;
|
||||
string message = 6;
|
||||
Signature author = 7;
|
||||
Signature committer = 8;
|
||||
uint64 change_number = 9;
|
||||
MergeMode mode = 10;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateMergeCommitResponse (var: At)
|
||||
message CreateMergeCommitResponse {
|
||||
string merge_commit_sha = 1;
|
||||
string wal_entry_key = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateRepoRequest (var: wn)
|
||||
message CreateRepoRequest {
|
||||
string repo_uuid = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.CreateRepoResponse (var: En)
|
||||
message CreateRepoResponse {
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.DiffHeader (var: Cn)
|
||||
message DiffHeader {
|
||||
string merge_base_commit_sha = 1;
|
||||
repeated ChangedFile files = 2;
|
||||
repeated ChangedFileWithStats files_with_stats = 3;
|
||||
bool has_more = 4;
|
||||
optional string next_page_cursor = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.DirectoryContent (var: qt)
|
||||
message DirectoryContent {
|
||||
repeated RepoContentEntry entries = 1;
|
||||
string sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FastForwardRefRequest (var: qn)
|
||||
message FastForwardRefRequest {
|
||||
string repo_uuid = 1;
|
||||
string target_ref = 2;
|
||||
string expected_head_sha = 3;
|
||||
string new_head_sha = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FastForwardRefResponse (var: Dn)
|
||||
message FastForwardRefResponse {
|
||||
string old_head_sha = 1;
|
||||
string new_head_sha = 2;
|
||||
string wal_entry_key = 3;
|
||||
bool unchanged = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileContent (var: Ft)
|
||||
message FileContent {
|
||||
string size = 1;
|
||||
string encoding = 2;
|
||||
string content = 3;
|
||||
string sha = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileDiff (var: bn)
|
||||
message FileDiff {
|
||||
string path = 1;
|
||||
optional string old_path = 2;
|
||||
bool is_binary = 3;
|
||||
string patch = 4;
|
||||
int32 additions = 5;
|
||||
int32 deletions = 6;
|
||||
optional FileMode old_mode = 7;
|
||||
optional FileMode new_mode = 8;
|
||||
optional string old_sha = 9;
|
||||
optional string new_sha = 10;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileHistoryCommitEntry (var: Zt)
|
||||
message FileHistoryCommitEntry {
|
||||
ShortCommit commit = 1;
|
||||
optional string diff_base_commit_sha = 2;
|
||||
optional FileHistoryPathStats path_stats = 8;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileHistoryPathStats (var: Xt)
|
||||
message FileHistoryPathStats {
|
||||
int32 additions = 1;
|
||||
int32 deletions = 2;
|
||||
bool is_binary = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileHistoryWithDiffStatsChunk (var: en)
|
||||
message FileHistoryWithDiffStatsChunk {
|
||||
repeated FileHistoryCommitEntry entries = 1;
|
||||
bool exhausted = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.FileMode (var: A)
|
||||
enum FileMode {
|
||||
FILE_MODE_UNSPECIFIED = 0;
|
||||
FILE_MODE_REGULAR = 1;
|
||||
FILE_MODE_EXECUTABLE = 2;
|
||||
FILE_MODE_SYMLINK = 3;
|
||||
FILE_MODE_GITLINK = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetBlameRequest (var: nn)
|
||||
message GetBlameRequest {
|
||||
string repo_uuid = 1;
|
||||
string start_commit_sha = 2;
|
||||
string path = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetBlobRequest (var: $e)
|
||||
message GetBlobRequest {
|
||||
string repo_uuid = 1;
|
||||
string blob_sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetBlobResponse (var: et)
|
||||
message GetBlobResponse {
|
||||
FileContent blob = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetCommitDiffRequest (var: vn)
|
||||
message GetCommitDiffRequest {
|
||||
string repo_uuid = 1;
|
||||
string commit_sha = 2;
|
||||
optional string base_commit_sha = 3;
|
||||
bool include_patches = 4;
|
||||
repeated string paths = 5;
|
||||
optional uint32 page_size = 6;
|
||||
optional string page_cursor = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetCommitRequest (var: Xe)
|
||||
message GetCommitRequest {
|
||||
string repo_uuid = 1;
|
||||
string commit_sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetCommitResponse (var: Ze)
|
||||
message GetCommitResponse {
|
||||
Commit commit = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFileHistoryPageWithDiffStatsResponse (var: tn)
|
||||
message GetFileHistoryPageWithDiffStatsResponse {
|
||||
repeated FileHistoryCommitEntry entries = 1;
|
||||
bool has_more = 2;
|
||||
optional string next_cursor = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFileHistoryRequest (var: Wt)
|
||||
message GetFileHistoryRequest {
|
||||
string repo_uuid = 1;
|
||||
string start_commit_sha = 2;
|
||||
optional string path = 3;
|
||||
uint32 max_commits = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFileHistoryResponse (var: zt)
|
||||
message GetFileHistoryResponse {
|
||||
repeated ShortCommit commits = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsRequest (var: jt)
|
||||
message GetFileHistoryWithDiffStatsRequest {
|
||||
string repo_uuid = 1;
|
||||
string start_commit_sha = 2;
|
||||
optional string path = 3;
|
||||
uint32 max_commits = 4;
|
||||
optional string next_cursor = 5;
|
||||
bool include_diff_stats = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFileHistoryWithDiffStatsResponse (var: $t)
|
||||
message GetFileHistoryWithDiffStatsResponse {
|
||||
repeated FileHistoryCommitEntry entries = 1;
|
||||
bool has_more = 2;
|
||||
optional string next_cursor = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFuzzyPathsRequest (var: un)
|
||||
message GetFuzzyPathsRequest {
|
||||
string repo_uuid = 1;
|
||||
string commit_sha = 2;
|
||||
string query = 3;
|
||||
uint32 limit = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetFuzzyPathsResponse (var: mn)
|
||||
message GetFuzzyPathsResponse {
|
||||
repeated string paths = 1;
|
||||
bool has_more = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetLocalDevInfoRequest (var: ze)
|
||||
message GetLocalDevInfoRequest {
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetLocalDevInfoResponse (var: je)
|
||||
message GetLocalDevInfoResponse {
|
||||
string repo_uuid = 1;
|
||||
string git_forge_root_dir = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetPullRequestDiffRequest (var: Sn)
|
||||
message GetPullRequestDiffRequest {
|
||||
string repo_uuid = 1;
|
||||
string head_commit_sha = 2;
|
||||
string base_commit_sha = 3;
|
||||
optional bool include_patches = 4;
|
||||
optional uint32 page_size = 5;
|
||||
optional string page_cursor = 6;
|
||||
optional bool include_file_stats = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetRepoContentDetailsRequest (var: Yt)
|
||||
message GetRepoContentDetailsRequest {
|
||||
string repo_uuid = 1;
|
||||
PathIdentifier path_identifier = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetRepoContentDetailsResponse (var: Kt)
|
||||
message GetRepoContentDetailsResponse {
|
||||
optional RepoContentDetails details = 1;
|
||||
PathIdentifier path_identifier = 2;
|
||||
string resolved_commit_sha = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetRepoContentRequest (var: Ut)
|
||||
message GetRepoContentRequest {
|
||||
string repo_uuid = 1;
|
||||
oneof id {
|
||||
PathIdentifier path_identifier = 2;
|
||||
string ref_and_path = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetRepoContentResponse (var: xt)
|
||||
message GetRepoContentResponse {
|
||||
PathIdentifier path_identifier = 3;
|
||||
string resolved_commit_sha = 4;
|
||||
oneof content {
|
||||
FileContent file_content = 1;
|
||||
DirectoryContent directory_content = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTagRequest (var: tt)
|
||||
message GetTagRequest {
|
||||
string repo_uuid = 1;
|
||||
string tag_sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTagResponse (var: nt)
|
||||
message GetTagResponse {
|
||||
Tag tag = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTreeBlameRequest (var: on)
|
||||
message GetTreeBlameRequest {
|
||||
string repo_uuid = 1;
|
||||
string start_commit_sha = 2;
|
||||
string path = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTreeBlameResponse (var: ln)
|
||||
message GetTreeBlameResponse {
|
||||
repeated TreeEntryBlame entries = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTreeRequest (var: rt)
|
||||
message GetTreeRequest {
|
||||
string repo_uuid = 1;
|
||||
string tree_sha = 2;
|
||||
bool recursive = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GetTreeResponse (var: st)
|
||||
message GetTreeResponse {
|
||||
Tree tree = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepLineKind (var: h)
|
||||
enum GrepLineKind {
|
||||
GREP_LINE_KIND_UNSPECIFIED = 0;
|
||||
GREP_LINE_KIND_MATCH = 1;
|
||||
GREP_LINE_KIND_CONTEXT = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepMatch (var: hn)
|
||||
message GrepMatch {
|
||||
string path = 1;
|
||||
string lines = 2;
|
||||
uint32 line_number = 3;
|
||||
uint64 absolute_offset = 4;
|
||||
repeated GrepSubmatch submatches = 5;
|
||||
GrepLineKind kind = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepRepoChunk (var: An)
|
||||
message GrepRepoChunk {
|
||||
repeated GrepMatch matches = 1;
|
||||
bool limit_hit = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepRepoRequest (var: fn)
|
||||
message GrepRepoRequest {
|
||||
string repo_uuid = 1;
|
||||
string revision = 2;
|
||||
string query = 3;
|
||||
GrepSearchOptions options = 4;
|
||||
uint32 max_results = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepSearchOptions (var: pn)
|
||||
message GrepSearchOptions {
|
||||
bool literal = 1;
|
||||
bool case_insensitive = 2;
|
||||
bool whole_word = 3;
|
||||
uint32 context_before = 4;
|
||||
uint32 context_after = 5;
|
||||
optional uint64 max_lines = 6;
|
||||
optional string filter_path = 7;
|
||||
repeated string includes = 8;
|
||||
repeated string excludes = 9;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.GrepSubmatch (var: gn)
|
||||
message GrepSubmatch {
|
||||
uint32 start = 1;
|
||||
uint32 end = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListCommitsInRangeRequest (var: mt)
|
||||
message ListCommitsInRangeRequest {
|
||||
string repo_uuid = 1;
|
||||
string base_revision = 2;
|
||||
string head_revision = 3;
|
||||
optional int32 max_commits = 4;
|
||||
bool oldest_first = 5;
|
||||
ListCommitsSort sort = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListCommitsInRangeResponse (var: ct)
|
||||
message ListCommitsInRangeResponse {
|
||||
repeated Commit commits = 1;
|
||||
string base_commit_sha = 2;
|
||||
string head_commit_sha = 3;
|
||||
string merge_base_commit_sha = 4;
|
||||
bool truncated = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListCommitsRequest (var: lt)
|
||||
message ListCommitsRequest {
|
||||
string repo_uuid = 1;
|
||||
string revision = 2;
|
||||
int32 page = 3;
|
||||
int32 per_page = 4;
|
||||
ListCommitsSort sort = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListCommitsResponse (var: ut)
|
||||
message ListCommitsResponse {
|
||||
repeated Commit commits = 1;
|
||||
optional int32 next_page = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListCommitsSort (var: y)
|
||||
enum ListCommitsSort {
|
||||
LIST_COMMITS_SORT_UNSPECIFIED = 0;
|
||||
LIST_COMMITS_SORT_COMMIT_TIME = 1;
|
||||
LIST_COMMITS_SORT_TOPOLOGICAL = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListRefsFilter (var: T)
|
||||
enum ListRefsFilter {
|
||||
LIST_REFS_FILTER_UNSPECIFIED = 0;
|
||||
LIST_REFS_FILTER_ALL = 1;
|
||||
LIST_REFS_FILTER_BRANCHES = 2;
|
||||
LIST_REFS_FILTER_TAGS = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListRefsRequest (var: Mn)
|
||||
message ListRefsRequest {
|
||||
string repo_uuid = 1;
|
||||
ListRefsFilter filter = 2;
|
||||
bool names_only = 3;
|
||||
string prefix = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListRefsResponse (var: Fn)
|
||||
message ListRefsResponse {
|
||||
repeated string refs = 1;
|
||||
repeated RefInfo ref_infos = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListTreePathsRequest (var: cn)
|
||||
message ListTreePathsRequest {
|
||||
string repo_uuid = 1;
|
||||
string revision = 2;
|
||||
repeated string includes = 3;
|
||||
repeated string excludes = 4;
|
||||
uint32 limit = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ListTreePathsResponse (var: dn)
|
||||
message ListTreePathsResponse {
|
||||
repeated string paths = 1;
|
||||
bool has_more = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.MergeMode (var: f)
|
||||
enum MergeMode {
|
||||
MERGE_MODE_UNSPECIFIED = 0;
|
||||
MERGE_MODE_MERGE_COMMIT = 1;
|
||||
MERGE_MODE_SQUASH = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.NotifyRepoPushedRequest (var: _n)
|
||||
message NotifyRepoPushedRequest {
|
||||
string repo_uuid = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.NotifyRepoPushedResponse (var: Tn)
|
||||
message NotifyRepoPushedResponse {
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.PathIdentifier (var: Lt)
|
||||
message PathIdentifier {
|
||||
string revision = 1;
|
||||
string path = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.PrepareChangeMergeRequest (var: yt)
|
||||
message PrepareChangeMergeRequest {
|
||||
string repo_uuid = 1;
|
||||
string base_ref = 2;
|
||||
string head_ref = 3;
|
||||
uint64 change_number = 4;
|
||||
optional string expected_base_sha = 5;
|
||||
optional string expected_head_sha = 6;
|
||||
MergeMode mode = 7;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.PrepareChangeMergeResponse (var: kt)
|
||||
message PrepareChangeMergeResponse {
|
||||
bool mergeable = 1;
|
||||
optional string merged_tree_sha = 2;
|
||||
optional string change_merge_ref = 3;
|
||||
optional string dummy_commit_sha = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.PullRequestDiffChunk (var: In)
|
||||
message PullRequestDiffChunk {
|
||||
optional DiffHeader header = 1;
|
||||
repeated FileDiff file_diffs = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackBranch (var: Et)
|
||||
message RebaseStackBranch {
|
||||
string head_ref = 1;
|
||||
string expected_old_oid = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackBranchUpdate (var: vt)
|
||||
message RebaseStackBranchUpdate {
|
||||
string head_ref = 1;
|
||||
string old_oid = 2;
|
||||
string new_oid = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackConflict (var: Bt)
|
||||
message RebaseStackConflict {
|
||||
string conflicted_head_ref = 1;
|
||||
repeated string conflicted_paths = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackRequest (var: wt)
|
||||
message RebaseStackRequest {
|
||||
string repo_uuid = 1;
|
||||
string onto_ref = 2;
|
||||
optional string expected_onto_oid = 3;
|
||||
repeated RebaseStackBranch branches = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackResponse (var: St)
|
||||
message RebaseStackResponse {
|
||||
oneof result {
|
||||
RebaseStackSuccess success = 1;
|
||||
RebaseStackConflict conflict = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RebaseStackSuccess (var: It)
|
||||
message RebaseStackSuccess {
|
||||
string wal_entry_key = 1;
|
||||
repeated RebaseStackBranchUpdate updates = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RefInfo (var: Ln)
|
||||
message RefInfo {
|
||||
string name = 1;
|
||||
string target_sha = 2;
|
||||
string object_sha = 3;
|
||||
string object_type = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RepoContentDetails (var: Vt)
|
||||
message RepoContentDetails {
|
||||
string type = 1;
|
||||
optional uint64 size = 2;
|
||||
bool is_binary = 3;
|
||||
bool too_large_to_introspect = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.RepoContentEntry (var: kn)
|
||||
message RepoContentEntry {
|
||||
string type = 1;
|
||||
string name = 2;
|
||||
string path = 3;
|
||||
string sha = 4;
|
||||
optional uint64 size = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ResolveRefPathRequest (var: Dt)
|
||||
message ResolveRefPathRequest {
|
||||
string repo_uuid = 1;
|
||||
string ref_path = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ResolveRefPathResponse (var: Ot)
|
||||
message ResolveRefPathResponse {
|
||||
PathIdentifier path_identifier = 1;
|
||||
string resolved_commit_sha = 2;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.ShortCommit (var: yn)
|
||||
message ShortCommit {
|
||||
bytes sha = 1;
|
||||
string summary = 2;
|
||||
string author_name = 3;
|
||||
string author_email = 4;
|
||||
int64 timestamp = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.Signature (var: Mt)
|
||||
message Signature {
|
||||
string name = 1;
|
||||
string email = 2;
|
||||
int64 timestamp = 3;
|
||||
int32 timezone_offset = 4;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.Tag (var: at)
|
||||
message Tag {
|
||||
string sha = 1;
|
||||
string name = 2;
|
||||
string message = 3;
|
||||
Signature tagger = 4;
|
||||
string object_sha = 5;
|
||||
string object_type = 6;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.Tree (var: ot)
|
||||
message Tree {
|
||||
string sha = 1;
|
||||
repeated TreeEntry tree = 2;
|
||||
bool truncated = 3;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.TreeEntry (var: it)
|
||||
message TreeEntry {
|
||||
string path = 1;
|
||||
string mode = 2;
|
||||
string type = 3;
|
||||
string sha = 4;
|
||||
optional uint64 size = 5;
|
||||
}
|
||||
|
||||
// Copied from: local:git_forge.v1.TreeEntryBlame (var: an)
|
||||
message TreeEntryBlame {
|
||||
string name = 1;
|
||||
ShortCommit last_commit = 2;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package internapi.v1;
|
||||
|
||||
option go_package = "react-admin/cursor-server/gen/internapi/v1;internapiv1";
|
||||
|
||||
// Copied from: local:internapi.v1.BlobData (var: hxe)
|
||||
// Copied from: local:internapi.v1.BlobData (var: Mn)
|
||||
message BlobData {
|
||||
BlobType blob_type = 1;
|
||||
bytes blob_id = 2;
|
||||
@@ -14,12 +14,12 @@ message BlobData {
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from: local:internapi.v1.BlobDataPerMessage (var: Txe)
|
||||
// Copied from: local:internapi.v1.BlobDataPerMessage (var: Ln)
|
||||
message BlobDataPerMessage {
|
||||
repeated BlobData blob_data = 1;
|
||||
}
|
||||
|
||||
// Copied from: local:internapi.v1.BlobType (var: Axe)
|
||||
// Copied from: local:internapi.v1.BlobType (var: Nn)
|
||||
enum BlobType {
|
||||
BLOB_TYPE_UNSPECIFIED = 0;
|
||||
BLOB_TYPE_IMAGE = 1;
|
||||
@@ -34,7 +34,7 @@ enum BlobType {
|
||||
BLOB_TYPE_VIDEO = 10;
|
||||
}
|
||||
|
||||
// Copied from: local:internapi.v1.ImageBlobData (var: pxe)
|
||||
// Copied from: local:internapi.v1.ImageBlobData (var: bn)
|
||||
message ImageBlobData {
|
||||
string mime_type = 1;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-7
@@ -8,11 +8,6 @@ QQ交流群:
|
||||
Tg群组:
|
||||
https://t.me/cursor_byok
|
||||
|
||||
- 修复commands无法识别的问题 @DedSecer
|
||||
- 修复 summarize 指令(主动压缩) @DedSecer
|
||||
- 修复 MiniMax 禁止 thinking 的问题 @Octopus
|
||||
- 支持Fork message (需要新开对话) @DedSecer
|
||||
- 支持 @关联对话 @DedSecer
|
||||
- 支持插件市场(须登录你的任意账号) @aike1202
|
||||
- 新增支持 cursor debugger 调试器, 用法请看.agents/skills/coding-guidance/SKILL.md
|
||||
- 支持cursor-cli
|
||||
- 修复对话中错误可能导致的消失问题
|
||||
|
||||
|
||||
Reference in New Issue
Block a user