Merge branch 'main' into release/0.0.46

This commit is contained in:
leookun
2026-08-07 02:28:54 +08:00
79 changed files with 35924 additions and 11465 deletions
+12
View File
@@ -342,3 +342,15 @@ go run ./cmd/cursor-proxy-debugger
- `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 仍遵守独立过滤规则。
-6
View File
@@ -10,9 +10,3 @@
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
## 检查清单 / Checklist
- [ ] 代码已通过 `gofmt``go vet` / Code passes `gofmt` and `go vet`
- [ ] 前端构建无报错 / Frontend builds without errors
- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files
- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits
+1
View File
@@ -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-*
+9 -2
View File
@@ -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}}'
+3 -1
View File
@@ -2,7 +2,7 @@
[中文](README.md) | [English](README.en.md)
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
## Start
@@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
- Captured traffic is stored only in process memory and is discarded when the process exits.
+3 -1
View File
@@ -2,7 +2,7 @@
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
## 启动
@@ -51,6 +51,8 @@ go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 相关的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone``UploadConversationBlobs` 流量。
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。
+64 -6
View File
@@ -20,6 +20,13 @@ import (
const maxConnectFrameBytes = 64 << 20
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
)
type connectFrameDecoder struct {
buffer []byte
messageType string
@@ -140,10 +147,9 @@ func decompressPayload(payload []byte, codec string) ([]byte, error) {
return decoded, nil
}
func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
var message proto.Message
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
switch path {
case "/aiserver.v1.BidiService/BidiAppend":
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", err
@@ -165,13 +171,65 @@ func decodeUnary(path string, payload []byte) (decodedJSON string, kind string,
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, marshalErr
default:
message = nil
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", nil
}
return marshalProtoJSON(message), activeOneofName(message), "", nil
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", err
}
return marshalProtoJSON(message), kind, "", nil
}
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
message, kind := unaryResponseMessage(path)
if message == nil {
return "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", err
}
return marshalProtoJSON(message), kind, nil
}
func unaryRequestMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
default:
return nil, ""
}
}
func unaryResponseMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
default:
return nil, ""
}
}
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
func newMessage(messageType string) proto.Message {
+242
View File
@@ -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()
}
+41 -15
View File
@@ -226,28 +226,29 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
if id == "" || response == nil {
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = response.Header.Get("Content-Type")
exchange.Response.ContentCodec = responseContentCodec(response.Header)
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, nil, 0, false, nil)
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"agent.v1.AgentServerMessage",
response.Header.Get("Connect-Content-Encoding"),
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
@@ -264,7 +265,7 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, captured, size, truncated, readErr)
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
},
)
return response
@@ -273,14 +274,14 @@ func (server *Server) captureResponse(response *http.Response, context *goproxy.
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
if decodesUnaryRequest(path) && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
@@ -312,19 +313,44 @@ func requestContentCodec(path string, headers http.Header) string {
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func responseContentCodec(headers http.Header) string {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
func responseContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if decodesUnaryResponse(path) && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
if decodedJSON != "" {
exchange.Response.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.ResponseKind = kind
}
if decodeErr != nil {
exchange.Response.DecodeError = decodeErr.Error()
}
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
+8
View File
@@ -138,6 +138,7 @@ function filteredExchanges() {
.filter((item) => {
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
@@ -151,6 +152,13 @@ function filteredExchanges() {
});
}
function isForkTrafficPath(path) {
const normalized = String(path || "").toLowerCase();
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
normalized.includes(endpoint),
);
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
+2
View File
@@ -22,6 +22,7 @@ const messages = {
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.fork": "Fork",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
@@ -81,6 +82,7 @@ const messages = {
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.fork": "Fork",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
+1
View File
@@ -46,6 +46,7 @@
<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>
+1
View File
@@ -17,6 +17,7 @@
"copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1",
"sortablejs": "^1.15.7",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
@@ -196,6 +196,7 @@ onBeforeUnmount(() => {
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAd"
>
<Transition name="ad-frame">
<iframe
@@ -2,6 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
@@ -13,6 +14,7 @@ import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const message = useMessage();
const cursorAccountStatus = ref({
state: "signed_out",
@@ -35,18 +37,16 @@ const cursorAccountStateText = computed(() => {
return "未连接";
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
async function handleOpenContributor() {
try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) {
await showActionError("打开贡献者主页失败", toUserError(error));
showActionError("打开贡献者主页失败", toUserError(error));
}
}
@@ -59,7 +59,7 @@ async function handleCursorAccountLogin() {
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
await showActionError("登录失败", toUserError(error));
showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
@@ -80,7 +80,7 @@ async function handleCursorAccountDisconnect() {
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
await showActionError("退出登录失败", toUserError(error));
showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
@@ -51,7 +51,6 @@ const props = defineProps({
type: Object,
default: () => createEmptyModelAdapter(),
},
errorMessage: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "save"]);
@@ -286,13 +285,6 @@ function handleSave() {
/>
</label>
<div
v-if="errorMessage"
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleSave">保存</Button>
@@ -44,7 +44,7 @@ const summaryText = computed(() => {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
return "测试失败,请查看原始信息";
}
return props.emptyText;
});
@@ -100,16 +100,16 @@ const summaryClass = computed(() => {
</script>
<template>
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
<div class="rounded-[8px] border-none px-3 py-3" :class="panelClass">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<div
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-white/80' : 'text-[11px] uppercase tracking-[0.08em] text-white/80'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-white/80">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
@@ -118,12 +118,7 @@ const summaryClass = computed(() => {
{{ summaryText }}
</div>
</div>
<span
v-if="stale"
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
@@ -134,19 +129,19 @@ const summaryClass = computed(() => {
v-if="showMetrics && normalizedStatus === 'success'"
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
<div class="mt-1 text-sm text-white/80">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="rounded-[8px] bg-black/20 px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
<div class="mt-1 text-sm text-white/80">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]"
class="mt-2 text-xs text-white/80"
>
输出 Token 为估算值
</div>
@@ -1,10 +1,11 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Combobox from "@/components/ui/Combobox.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi";
import { useMessage } from "@/composables/useMessage";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
@@ -12,6 +13,7 @@ import {
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
fetchAvailableModelIDs,
getModelAdapterTestResult,
getModelAdapterTestResultByID,
isModelAdapterTestResultStale,
@@ -25,8 +27,7 @@ import {
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { Window } from "@wailsio/runtime";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
@@ -55,12 +56,25 @@ const openAIEndpointOptions = [
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
];
const editorIndex = ref(-1);
const draft = reactive(createEmptyModelAdapter());
const errorMessage = ref("");
const loading = ref(true);
const props = defineProps({
index: { type: Number, default: -1 },
adapter: { type: Object, default: () => createEmptyModelAdapter() },
});
const emit = defineEmits(["close", "saved"]);
const message = useMessage();
const editorIndex = ref(props.index);
const draft = reactive(normalizeModelAdapter(props.adapter));
if (!draft.type) {
draft.type = "openai";
}
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
const availableModelIDs = ref(draft.modelID ? [draft.modelID] : []);
const modelListLoading = ref(false);
const modelListRequestSeq = ref(0);
let modelListDebounceTimer = 0;
function createOptionalPositiveIntegerModel(key) {
return computed({
@@ -80,14 +94,23 @@ 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 canFetchModels = computed(() => Boolean(
draft.type && String(draft.baseURL || "").trim() && String(draft.apiKey || "").trim(),
));
const selectedTestAdapter = computed(() => 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(() => {
@@ -97,8 +120,6 @@ const modelTestSummary = computed(() => {
return activeModelTestResult.value?.summaryText || "尚未测试";
});
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
@@ -125,7 +146,7 @@ function ensureAnthropicThinkingEffort() {
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
@@ -140,20 +161,42 @@ const fieldTips = {
tooltipData: "模型列表 hover 时显示的备注说明。",
};
async function loadContext() {
async function refreshModelList() {
const baseURL = String(draft.baseURL || "").trim();
const apiKey = String(draft.apiKey || "").trim();
if (!baseURL || !apiKey || !draft.type) {
modelListRequestSeq.value += 1;
availableModelIDs.value = [];
modelListLoading.value = false;
return [];
}
const requestSeq = modelListRequestSeq.value + 1;
modelListRequestSeq.value = requestSeq;
modelListLoading.value = true;
availableModelIDs.value = [];
try {
const ctx = await getModelEditorContext();
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
const parsed = JSON.parse(ctx.adapterJSON || "{}");
Object.assign(draft, normalizeModelAdapter(parsed));
if (!draft.type) {
draft.type = "openai";
const models = await fetchAvailableModelIDs({
type: draft.type,
baseURL,
apiKey,
customHeadersEnabled: draft.customHeadersEnabled,
customHeadersJSON: draft.customHeadersJSON,
});
if (requestSeq !== modelListRequestSeq.value) {
return availableModelIDs.value;
}
availableModelIDs.value = models;
return models;
} catch (_error) {
Object.assign(draft, createEmptyModelAdapter());
draft.type = "openai";
if (requestSeq === modelListRequestSeq.value) {
availableModelIDs.value = [];
}
return availableModelIDs.value;
} finally {
loading.value = false;
if (requestSeq === modelListRequestSeq.value) {
modelListLoading.value = false;
}
}
}
@@ -162,13 +205,13 @@ async function persistDraft() {
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
errorMessage.value = singleCheck;
message(singleCheck);
return { ok: false, error: singleCheck, adapter: null };
}
const result = await saveModelAdapterAt(editorIndex.value, adapter);
if (!result.ok) {
errorMessage.value = result.error;
message(result.error);
return { ok: false, error: result.error, adapter: null };
}
@@ -177,12 +220,13 @@ 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),
};
}
@@ -191,15 +235,20 @@ async function handleSave() {
if (!result.ok) {
return;
}
await Window.Close();
emit("saved", result.adapter);
emit("close");
}
async function handleCancel() {
await Window.Close();
function handleCancel() {
emit("close");
}
function handleModelTypeChange(type) {
draft.type = type;
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = [];
draft.modelID = "";
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
@@ -273,31 +322,40 @@ watch(
},
);
onMounted(async () => {
await loadContext();
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) {
modelListRequestSeq.value += 1;
modelListLoading.value = false;
availableModelIDs.value = [];
return;
}
modelListDebounceTimer = window.setTimeout(() => {
void refreshModelList();
}, 600);
},
{ immediate: true },
);
onBeforeUnmount(() => {
window.clearTimeout(modelListDebounceTimer);
});
</script>
<template>
<div class="flex h-full flex-col text-[#e5e5e5]">
<div class="flex shrink-0 items-center justify-between px-4 pb-2">
<h2 class="text-base font-medium text-white">{{ title }}</h2>
<div class="flex items-center gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
</Button>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
{{ appState.configSaving ? "保存中..." : "保存" }}
</Button>
</div>
</div>
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-[#a3a3a3]">
加载中...
</div>
<div v-else class="flex-1 overflow-y-auto min-h-0 px-4 pb-4">
<div class="flex-shrink-0 p-4" v-if="localTestFailure || activeModelTestResult">
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 scroll-shadow-bottom">
<div class="flex flex-col gap-4">
<div class="center-row gap-2">
<button
@@ -318,26 +376,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 +403,43 @@ 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="fieldTips.displayName" />
<span>显示名称</span>
</span>
<input
v-model="draft.baseURL"
v-model="draft.displayName"
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="fieldTips.modelID" />
<span>模型标识</span>
</span>
<Combobox
v-model="draft.modelID"
:options="modelOptions"
:loading="modelListLoading"
placeholder="例如:gpt-4.1"
empty-text="没有匹配的模型"
aria-label="选择模型"
>
<template #append>
<button
type="button"
class="center-row h-9 shrink-0 gap-1.5 whitespace-nowrap rounded-[6px] border border-[#3f3f3f] bg-[#292929] px-[8px] text-sm text-[#d4d4d4] outline-none transition-colors hover:border-[#505050] hover:bg-[#303030] hover:text-white focus-visible:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="modelListLoading || !canFetchModels"
@click="refreshModelList"
>
<span>获取模型</span>
</button>
</template>
</Combobox>
</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" />
@@ -533,19 +604,16 @@ onMounted(async () => {
/>
</label>
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
<div
v-if="errorMessage"
class="rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 px-4 py-3">
<Button variant="default" :disabled="appState.configSaving" @click="handleCancel">取消</Button>
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
</Button>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
{{ appState.configSaving ? "保存中..." : "保存" }}
</Button>
</div>
</div>
</template>
+3 -6
View File
@@ -1,12 +1,9 @@
<script setup></script>
<template>
<div
class="rounded-[8px] p-[1px]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="rounded-[8px] border border-[#343434] bg-[#292929] p-4 "
>
<div class="rounded-[7px] bg-[#292929] p-4">
<slot />
</div>
<slot />
</div>
</template>
+337
View File
@@ -0,0 +1,337 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, useId, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: "" },
emptyText: { type: String, default: "没有匹配项" },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
ariaLabel: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const inputRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const showAllOptions = ref(false);
const menuStyle = ref({});
const listboxID = useId();
const normalizedOptions = computed(() => props.options.map((option, optionIndex) => {
if (typeof option === "string") {
return { label: option, value: option, icon: "", optionIndex };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? option?.iconClass ?? "",
optionIndex,
};
}));
const filteredOptions = computed(() => {
const query = String(props.modelValue || "").trim().toLocaleLowerCase();
if (showAllOptions.value || !query) {
return normalizedOptions.value;
}
return normalizedOptions.value.filter((option) => (
String(option.label).toLocaleLowerCase().includes(query)
|| String(option.value).toLocaleLowerCase().includes(query)
));
});
const activeOption = computed(() => filteredOptions.value[activeIndex.value] ?? null);
const activeDescendant = computed(() => (
isOpen.value && activeOption.value ? `${listboxID}-option-${activeOption.value.optionIndex}` : undefined
));
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => optionRefs.value[activeOption.value?.optionIndex]?.scrollIntoView({ block: "nearest" }));
}
function openMenu({ showAll = false } = {}) {
if (props.disabled || normalizedOptions.value.length === 0) {
return;
}
showAllOptions.value = showAll;
isOpen.value = true;
const selectedIndex = filteredOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : filteredOptions.value.length ? 0 : -1;
nextTick(updatePosition);
}
function closeMenu({ restoreFocus = false, notifyBlur = false } = {}) {
if (!isOpen.value) {
if (notifyBlur) {
emit("blur");
}
return;
}
isOpen.value = false;
activeIndex.value = -1;
if (restoreFocus) {
nextTick(() => inputRef.value?.focus());
}
if (notifyBlur) {
emit("blur");
}
}
function handleInput(event) {
emit("update:modelValue", event.target.value);
showAllOptions.value = false;
isOpen.value = normalizedOptions.value.length > 0;
activeIndex.value = filteredOptions.value.length ? 0 : -1;
}
function selectOption(option) {
if (!option) {
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu();
}
function toggleMenu() {
if (isOpen.value) {
closeMenu({ restoreFocus: true });
return;
}
openMenu({ showAll: true });
nextTick(() => inputRef.value?.focus());
}
function moveActiveIndex(step) {
if (!isOpen.value) {
openMenu({ showAll: true });
return;
}
const total = filteredOptions.value.length;
if (!total) {
return;
}
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
function handleInputKeydown(event) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
if (isOpen.value && activeIndex.value >= 0) {
event.preventDefault();
selectOption(filteredOptions.value[activeIndex.value]);
}
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu({ restoreFocus: true });
}
break;
case "Tab":
closeMenu({ notifyBlur: true });
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu({ notifyBlur: true });
}
function updatePosition() {
if (!rootRef.value || !menuRef.value) {
return;
}
computePosition(rootRef.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, {
width: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 160)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = { left: `${x}px`, top: `${y}px` };
});
}
watch(filteredOptions, (options) => {
if (!isOpen.value) {
return;
}
activeIndex.value = options.length ? Math.min(Math.max(activeIndex.value, 0), options.length - 1) : -1;
nextTick(updatePosition);
});
watch(normalizedOptions, (options) => {
if (options.length === 0) {
closeMenu();
}
});
watchPostEffect((cleanup) => {
if (!isOpen.value || !rootRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(rootRef.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 class="flex w-full min-w-0 items-center gap-2">
<div
ref="rootRef"
class="flex h-9 min-w-0 flex-1 items-center rounded-[6px] border border-[#3f3f3f] bg-[#232323] transition-colors focus-within:border-[#10AD5D]"
>
<input
ref="inputRef"
:value="modelValue"
type="text"
role="combobox"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="min-w-0 flex-1 bg-transparent px-3 text-sm text-[#e5e5e5] outline-none placeholder:text-[#7b7b7b] disabled:cursor-not-allowed disabled:opacity-60"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
:aria-controls="listboxID"
:aria-activedescendant="activeDescendant"
aria-autocomplete="list"
aria-haspopup="listbox"
@focus="openMenu({ showAll: true })"
@input="handleInput"
@change="emit('change', $event.target.value)"
@keydown="handleInputKeydown"
/>
<button
type="button"
class="center-row h-full w-9 shrink-0 text-[#8f8f8f] outline-none transition-colors hover:text-[#d4d4d4] disabled:cursor-not-allowed disabled:opacity-60"
:disabled="disabled || loading"
:aria-label="ariaLabel || undefined"
:aria-expanded="isOpen"
tabindex="-1"
@mousedown.prevent
@click="toggleMenu"
>
<span v-if="loading" class="icon-[mdi--loading] animate-spin text-[17px]"></span>
<span v-else class="icon-[mdi--chevron-down] text-[18px] transition-transform duration-200" :class="isOpen ? 'rotate-180' : ''"></span>
</button>
</div>
<div v-if="$slots.append" class="shrink-0">
<slot name="append" />
</div>
</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-show="isOpen && normalizedOptions.length"
ref="menuRef"
:id="listboxID"
role="listbox"
class="fixed z-[999] overflow-y-auto rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:style="menuStyle"
>
<ul v-show="filteredOptions.length" role="presentation" class="py-1">
<li
v-for="option in normalizedOptions"
v-show="filteredOptions.includes(option)"
:key="option.value"
role="presentation"
>
<button
:ref="(el) => setOptionRef(el, option.optionIndex)"
:id="`${listboxID}-option-${option.optionIndex}`"
type="button"
role="option"
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
option.value === modelValue
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeOption === option ? 'bg-[#303030]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="-1"
@mousedown.prevent
@click="selectOption(option)"
@mouseenter="activeIndex = filteredOptions.indexOf(option)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'shrink-0 text-[16px]']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
<div v-show="!filteredOptions.length" class="px-3 py-2 text-sm text-[#8f8f8f]">{{ emptyText }}</div>
</div>
</Transition>
</Teleport>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: "" },
size: {
type: String,
default: "md",
validator: (value) => ["md", "lg", "xl"].includes(value),
},
closeOnBackdrop: { type: Boolean, default: true },
closeOnEscape: { type: Boolean, default: true },
closeDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["close"]);
const panelRef = ref(null);
const closeButtonRef = ref(null);
let previouslyFocusedElement = null;
const panelClass = computed(() => ({
md: "max-w-[420px]",
lg: "max-w-[680px]",
xl: "h-full max-h-[760px] max-w-[880px]",
}[props.size]));
function requestClose() {
if (!props.closeDisabled) {
emit("close");
}
}
function handleBackdropClick() {
if (props.closeOnBackdrop) {
requestClose();
}
}
function handleKeydown(event) {
if (event.key === "Escape" && props.open && props.closeOnEscape) {
requestClose();
return;
}
if (event.key !== "Tab" || !props.open || !panelRef.value) {
return;
}
const focusable = Array.from(panelRef.value.querySelectorAll(
"button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
));
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
watch(
() => props.open,
(open) => {
if (open) {
previouslyFocusedElement = document.activeElement;
document.addEventListener("keydown", handleKeydown);
nextTick(() => {
const firstField = panelRef.value?.querySelector(
"input:not([disabled]), textarea:not([disabled]), select:not([disabled])",
);
(firstField || closeButtonRef.value)?.focus();
});
return;
}
document.removeEventListener("keydown", handleKeydown);
if (previouslyFocusedElement instanceof HTMLElement && document.contains(previouslyFocusedElement)) {
previouslyFocusedElement.focus();
}
previouslyFocusedElement = null;
},
{ immediate: true },
);
onBeforeUnmount(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="open"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-7 pb-[38px] pt-[calc(48px+env(safe-area-inset-top))]"
@click.self="handleBackdropClick"
>
<section
ref="panelRef"
role="dialog"
aria-modal="true"
:aria-label="title || undefined"
class="flex w-full max-h-[660px] min-h-0 flex-col overflow-hidden rounded-[8px] border border-[#3a3a3a] bg-[#202020] shadow-[0_24px_64px_rgba(0,0,0,0.65)]"
:class="panelClass"
@click.stop
>
<header class="flex h-12 shrink-0 items-center justify-between border-b border-[#343434] px-4">
<h2 class="min-w-0 truncate text-base font-medium text-white">{{ title }}</h2>
<button
ref="closeButtonRef"
type="button"
class="center-row w-[28px] h-[28px] justify-center size-8 shrink-0 rounded-[6px] text-[#8f8f8f] outline-none transition-colors hover:bg-[#303030] hover:text-white focus-visible:ring-2 focus-visible:ring-[#10AD5D]/40 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="closeDisabled"
aria-label="关闭"
@click="requestClose"
>
<span class="icon-[mdi--close] text-[19px]"></span>
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
+17 -54
View File
@@ -2,56 +2,24 @@
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-[#10AD5D] text-white",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#D84C4C] text-white",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#F08A24] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#3a3a3a] text-white",
iconClass: "icon-[mingcute--loading-fill]",
iconExtraClass: "animate-spin",
},
};
function resolveTheme(type) {
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
}
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
:class="resolveTheme(messageState.current.type).containerClass"
>
<span
v-if="resolveTheme(messageState.current.type).iconClass"
class="text-[14px]"
:class="[
resolveTheme(messageState.current.type).iconClass,
resolveTheme(messageState.current.type).iconExtraClass,
]"
/>
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
</div>
</Transition>
</div>
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 top-[calc(48px+env(safe-area-inset-top))] z-[11000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
role="status"
aria-live="polite"
class="max-w-[min(520px,calc(100vw-32px))] rounded-[8px] border border-[#454545] bg-[#2b2b2b] px-4 py-2.5 text-center text-sm leading-5 text-[#ededed] shadow-[0_10px_30px_rgba(0,0,0,0.38)]"
>
{{ messageState.current.content }}
</div>
</Transition>
</div>
</Teleport>
</template>
<style scoped>
@@ -62,7 +30,7 @@ function resolveTheme(type) {
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
.message-slide-enter-to,
@@ -73,11 +41,6 @@ function resolveTheme(type) {
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
transform: translateY(-8px);
}
</style>
+2 -3
View File
@@ -39,11 +39,10 @@ function onMaskClick() {
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)] border border-[#3A3A3A] bg-[#292929]"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<div class="rounded-[7px] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
+1 -1
View File
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
<button
ref="triggerRef"
type="button"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-white/60 transition-colors duration-150 hover:text-white/80"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"
+17 -37
View File
@@ -35,10 +35,9 @@ function removeMessage(id, options = {}) {
messageState.current = null;
}
function showMessage(options = {}) {
const type = typeof options.type === "string" ? options.type : "info";
const content = String(options.content || "").trim();
if (!content) {
function showMessage(content, options = {}) {
const normalizedContent = String(content || "").trim();
if (!normalizedContent) {
return null;
}
@@ -48,14 +47,11 @@ function showMessage(options = {}) {
const duration = Number.isFinite(options.duration)
? Math.max(0, options.duration)
: type === "loading"
? 0
: 2400;
: 2400;
const id = `message-${Date.now()}-${messageSeed += 1}`;
const item = {
id,
type,
content,
content: normalizedContent,
shownAt: Date.now(),
timer: null,
};
@@ -70,40 +66,24 @@ function showMessage(options = {}) {
return id;
}
export function createMessageApi() {
return {
state: messageState,
show: showMessage,
success(content, options = {}) {
return showMessage({ ...options, type: "success", content });
},
error(content, options = {}) {
return showMessage({ ...options, type: "error", content });
},
info(content, options = {}) {
return showMessage({ ...options, type: "info", content });
},
loading(content, options = {}) {
return showMessage({ ...options, type: "loading", content });
},
remove: removeMessage,
clear() {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
},
};
export function message(content, options = {}) {
return showMessage(content, options);
}
const defaultMessageApi = createMessageApi();
message.remove = removeMessage;
message.clear = () => {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
};
export function provideMessage() {
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
return defaultMessageApi;
provide(MESSAGE_API_SYMBOL, message);
return message;
}
export function useMessage() {
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
return inject(MESSAGE_API_SYMBOL, message);
}
export { messageState, showMessage, removeMessage };
export { messageState, showMessage, removeMessage };
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "No release notes",
"02bc2e95bf49e587": "No",
"03b11112dc970014": "Base URL",
"04f632dd4f034d5e": "{0} context window must be a positive integer",
"051836569928a9f9": "Edit",
@@ -18,6 +17,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",
@@ -40,7 +40,6 @@
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.",
"358f07b2c1445ab1": "Author's Message",
"36c149a9b3e8dca0": "models configured yet.",
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
@@ -59,18 +58,20 @@
"472642d58d3d5a6d": "Formula: {0}",
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data",
"4b5e0ae1288a9695": "No matching models",
"4c0a929bb86ce912": "Current: {0}",
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
"4d8c1c5b42830791": "Unknown",
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
"51194c3ad014fb29": "Retest required",
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"54e6745ff43c9c74": "Sorting failed",
"56627c94a9decee6": "Max Output Tokens",
"58c6b0935a7216da": "Failed to open contributor profile",
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
"59a2195a01a8b35b": "{0} must be a valid JSON object",
"5aa8f5590c940829": "Non-cache Input: {0}",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5c55a67935af8f45": "All",
"5d1687a4a41883fd": "Stopping...",
"5e709712ce012f5d": "Current outbound requests use environment variable proxy: {0}",
"6106f0a12583a334": "Refresh failed",
@@ -92,6 +93,7 @@
"6e584e3d5ce64aa0": "Save Settings",
"6ec87609a8769425": "Default {0} / Count as creation {1}",
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
"737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed",
@@ -99,14 +101,16 @@
"77c9e582e85583af": "Test failed",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
"7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates",
"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.",
"8151e8704a7ca89e": "No matches",
"83913e71fcf7ff60": "Refresh successful",
"83be9cac28873059": "Cursor Control Plane Account",
"83fcfb4c1f2c1641": "Fetch Models",
"8672864e90417138": "Max",
"86df7ec743047234": "Service running",
"899add6275682210": "Uses 200000 by default when left blank",
@@ -114,7 +118,6 @@
"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",
@@ -122,7 +125,6 @@
"917b1c1f18d0276b": "Saving...",
"9196835e388d2550": "Test All",
"91cba5c107a51892": "/ Invalid",
"92059fe6cd713db4": "The model name actually sent to the server, for example gpt-4.1 or claude-sonnet.",
"93e08803675e378b": "Model ID",
"93faf55cd25c8319": "This software is completely free. If you were charged, you were likely scammed.\nWelcome to visit the author's homepage at https://space.bilibili.com/311706663/upload/video\nto see more updates, sharing guides, and future content.",
"942ff2d88baca0c6": "Checking for updates...",
@@ -135,7 +137,6 @@
"9c41b3a9e12ac994": "Thinking Effort",
"9d2ca261281a158a": "Later",
"9d2fb46c0ba890b9": "Custom Path",
"9dc0825fba5422e4": "Loading...",
"9e02529bcaef36c6": "Duplicate model channel detected. Please check the combination of url, modelID, apiKey, displayName, and endpoint",
"a026f37e613cf48b": "Output Tokens",
"a0d36236c523667c": "{0} Anthropic thinking effort only supports low, medium, high, xhigh, max",
@@ -153,6 +154,7 @@
"ac217e4d1ca410f1": "New version available",
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
"ae5a738238463a92": "Hide API Key",
"aeb1e3a8ff46cda1": "No models have been configured yet.",
"aed55419ce62f08e": "Switching...",
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "High",
@@ -160,6 +162,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",
@@ -172,6 +175,7 @@
"c228558cf257fc49": "Delete failed",
"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.",
"c6868592796ac2b2": "No {0} models have been configured yet.",
"c69f5bce63b9f14c": "Settings Folder",
"c8a52b66651d294c": "Failed to log out",
"c8c14507b2d37395": "Reasoning Effort",
@@ -179,6 +183,7 @@
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
"ca00a39fcea70dc6": "Starting...",
"ca1d1059408b3837": "Invalid turns: {0}",
"cc5049729a2c10f1": "Test failed. Check the raw details.",
"cd7ca5fb221e1c53": "{0} cannot be empty",
"cfa6c803eb3fc713": "Waiting for browser login",
"d0325067fed88e5a": "Cache hit rate {0}",
@@ -213,6 +218,8 @@
"ec3b17a75db49e24": "{0} t/s | First token {1}",
"ec99e5c45d648fd6": "Update failed",
"ee95057c6b0335d2": "Current outbound requests use system proxy: {0}",
"f0b6a23368dd47cc": "Enter a model ID directly, or select one from the list returned by the server.",
"f1aa7326f38b4c09": "Drag to reorder",
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
"f3a76d896853c1df": "Miss",
+16 -9
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "更新内容はありません",
"02bc2e95bf49e587": "まだ",
"03b11112dc970014": "ベース URL",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
"051836569928a9f9": "編集",
@@ -18,6 +17,7 @@
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
"18b7312022cd1840": "サービスを開始",
"1afed6a81a2512d2": "モデルを選択",
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
"1bc77f5ab979f4c1": "モデル設定を追加",
"1c631615c1d85c9e": "Cursor にログイン",
@@ -40,7 +40,6 @@
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください",
"358f07b2c1445ab1": "著者からのメッセージ",
"36c149a9b3e8dca0": "モデルは設定されていません。",
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
@@ -59,18 +58,20 @@
"472642d58d3d5a6d": "数式:{0}",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし",
"4b5e0ae1288a9695": "一致するモデルがありません",
"4c0a929bb86ce912": "現在:{0}",
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}{1})",
"4d8c1c5b42830791": "不明",
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
"51194c3ad014fb29": "再テストが必要",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"54e6745ff43c9c74": "並べ替えに失敗しました",
"56627c94a9decee6": "最大出力 Token",
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
"5aa8f5590c940829": "非キャッシュ入力:{0}",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5c55a67935af8f45": "すべて",
"5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}",
"6106f0a12583a334": "再読み込みに失敗しました",
@@ -92,21 +93,24 @@
"6e584e3d5ce64aa0": "設定を保存",
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
"736c9dc2a04c65fd": "モデル設定が変更されました。更新してからもう一度お試しください。",
"737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました",
"774d6e1b7cb89751": "デフォルト定義",
"77c9e582e85583af": "テスト失敗",
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
"7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認",
"80296f4aa3f4543b": "キャッシュ読み書き",
"81123c56d5d880d0": "API キー",
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
"8151e8704a7ca89e": "一致する項目がありません",
"83913e71fcf7ff60": "更新しました",
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
"83fcfb4c1f2c1641": "モデルを取得",
"8672864e90417138": "最大",
"86df7ec743047234": "サービス稼働中",
"899add6275682210": "空欄で 200000",
@@ -114,7 +118,6 @@
"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 +125,7 @@
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
"93e08803675e378b": "モデル ID",
"93e08803675e378b": "モデルID",
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
"942ff2d88baca0c6": "アップデートを確認中...",
"970388573a3c88c9": "キャッシュ読み取り:{0} × ${1}/1M = {2}",
@@ -135,7 +137,6 @@
"9c41b3a9e12ac994": "思考強度",
"9d2ca261281a158a": "後で",
"9d2fb46c0ba890b9": "カスタムパス",
"9dc0825fba5422e4": "読み込み中...",
"9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください",
"a026f37e613cf48b": "出力 Token",
"a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています",
@@ -153,6 +154,7 @@
"ac217e4d1ca410f1": "新しいバージョンがあります",
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
"ae5a738238463a92": "API キーを隠す",
"aeb1e3a8ff46cda1": "まだモデルが設定されていません。",
"aed55419ce62f08e": "切替中...",
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
@@ -160,6 +162,7 @@
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
"b765005f69fa971f": "例: gpt-4.1",
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
"b7ceef2fbfeb4a85": "例: GPT-5",
"b870928f8f9a24c4": "APIエンドポイント",
"b90a8ac9c488ce46": "言語を選択",
"ba3c66f90fd11725": "システムプロキシを認識しました",
@@ -172,6 +175,7 @@
"c228558cf257fc49": "削除に失敗しました",
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
"c69f5bce63b9f14c": "設定フォルダー",
"c8a52b66651d294c": "ログアウトに失敗しました",
"c8c14507b2d37395": "推論強度",
@@ -179,6 +183,7 @@
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
"ca00a39fcea70dc6": "起動中...",
"ca1d1059408b3837": "異常ターン: {0}",
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
"cd7ca5fb221e1c53": "{0}は空にできません",
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
@@ -213,6 +218,8 @@
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
"ec99e5c45d648fd6": "アップデートに失敗しました",
"ee95057c6b0335d2": "現在のアウトバウンドリクエストはシステムプロキシを使用しています:{0}",
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
"f1aa7326f38b4c09": "ドラッグして並べ替え",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス",
+15 -8
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "Нет примечаний к выпуску",
"02bc2e95bf49e587": "Пока нет настроенных",
"03b11112dc970014": "Базовый URL",
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
"051836569928a9f9": "Изменить",
@@ -18,6 +17,7 @@
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
"185aebe19c77425d": "{0} должен быть объектом JSON",
"18b7312022cd1840": "Запустить сервис",
"1afed6a81a2512d2": "Выберите модель",
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
"1bc77f5ab979f4c1": "Добавить настройки модели",
"1c631615c1d85c9e": "Войти в Cursor",
@@ -40,7 +40,6 @@
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
"358f07b2c1445ab1": "Сообщение автора",
"36c149a9b3e8dca0": "моделей.",
"37d23612f78a2e63": "Перезапустить и обновить",
"392d0dceb45998d3": "Очень высокая",
"393df9bb13ea4900": "Попадание",
@@ -59,18 +58,20 @@
"472642d58d3d5a6d": "Формула: {0}",
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
"497c85690c4cc0fc": "Нет данных",
"4b5e0ae1288a9695": "Подходящих моделей нет",
"4c0a929bb86ce912": "Сейчас: {0}",
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
"4d8c1c5b42830791": "Неизвестно",
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
"51194c3ad014fb29": "Требуется повторная проверка",
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
"54e6745ff43c9c74": "Не удалось изменить порядок",
"56627c94a9decee6": "Макс. выходных токенов",
"58c6b0935a7216da": "Не удалось открыть профиль участника",
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
"5aa8f5590c940829": "Ввод без кеша: {0}",
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
"5c55a67935af8f45": "Все",
"5d1687a4a41883fd": "Остановка...",
"5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}",
"6106f0a12583a334": "Не удалось обновить",
@@ -92,21 +93,24 @@
"6e584e3d5ce64aa0": "Сохранить настройки",
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
"736c9dc2a04c65fd": "Конфигурация моделей изменилась. Обновите данные и повторите попытку.",
"737225e2904673fc": "Расчетные выходные токены: {0}",
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
"753d8bb0da9913ce": "Не удалось дублировать",
"774d6e1b7cb89751": "Стандартный расчет",
"77c9e582e85583af": "Проверка не пройдена",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе и помогает различать модели.",
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
"7b6187c41e88b70c": "Проверка...",
"7bf8e2c07e084d09": "Редактор модели",
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
"7f68ebad19ba6bcd": "Проверить обновления",
"80296f4aa3f4543b": "Чтение/запись кеша",
"81123c56d5d880d0": "Ключ API",
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
"8151e8704a7ca89e": "Совпадений нет",
"83913e71fcf7ff60": "Обновление выполнено",
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
"83fcfb4c1f2c1641": "Получить модели",
"8672864e90417138": "Максимальная",
"86df7ec743047234": "Сервис запущен",
"899add6275682210": "Если оставить пустым, используется 200000",
@@ -114,7 +118,6 @@
"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 +125,6 @@
"917b1c1f18d0276b": "Сохранение...",
"9196835e388d2550": "Проверить все",
"91cba5c107a51892": "/ Ошибочные",
"92059fe6cd713db4": "Имя модели, которое фактически отправляется серверу, например gpt-4.1 или claude-sonnet.",
"93e08803675e378b": "Идентификатор модели",
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
"942ff2d88baca0c6": "Проверка обновлений...",
@@ -135,7 +137,6 @@
"9c41b3a9e12ac994": "Интенсивность рассуждений",
"9d2ca261281a158a": "Позже",
"9d2fb46c0ba890b9": "Пользовательский путь",
"9dc0825fba5422e4": "Загрузка...",
"9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint",
"a026f37e613cf48b": "Выходные токены",
"a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max",
@@ -153,6 +154,7 @@
"ac217e4d1ca410f1": "Доступна новая версия",
"ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей",
"ae5a738238463a92": "Скрыть ключ API",
"aeb1e3a8ff46cda1": "Модели пока не настроены.",
"aed55419ce62f08e": "Переключение...",
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
"b1c27820fec23edb": "Высокая",
@@ -160,6 +162,7 @@
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
"b765005f69fa971f": "например, gpt-4.1",
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
"b7ceef2fbfeb4a85": "например, GPT-5",
"b870928f8f9a24c4": "Конечная точка API",
"b90a8ac9c488ce46": "Выберите язык",
"ba3c66f90fd11725": "Обнаружен системный прокси",
@@ -172,6 +175,7 @@
"c228558cf257fc49": "Не удалось удалить",
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
"c6868592796ac2b2": "Модели {0} пока не настроены.",
"c69f5bce63b9f14c": "Папка настроек",
"c8a52b66651d294c": "Не удалось выйти",
"c8c14507b2d37395": "Интенсивность рассуждений",
@@ -179,6 +183,7 @@
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
"ca00a39fcea70dc6": "Запуск...",
"ca1d1059408b3837": "Ошибочных ходов: {0}",
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
"cd7ca5fb221e1c53": "{0} не может быть пустым",
"cfa6c803eb3fc713": "Ожидание входа в браузере",
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
@@ -213,6 +218,8 @@
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
"ec99e5c45d648fd6": "Не удалось обновить",
"ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}",
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
"f3a76d896853c1df": "Промах",
+14 -7
View File
@@ -1,6 +1,5 @@
{
"02216368edc68816": "无更新说明",
"02bc2e95bf49e587": "当前还没有配置任何",
"03b11112dc970014": "接口地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
"051836569928a9f9": "编辑",
@@ -18,6 +17,7 @@
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
"185aebe19c77425d": "{0}必须是 JSON 对象",
"18b7312022cd1840": "启动服务",
"1afed6a81a2512d2": "选择模型",
"1baddde657dd2720": "当前出站请求使用系统代理",
"1bc77f5ab979f4c1": "新增模型配置",
"1c631615c1d85c9e": "登录 Cursor",
@@ -40,7 +40,6 @@
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试",
"358f07b2c1445ab1": "作者寄语",
"36c149a9b3e8dca0": "模型。",
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
@@ -59,18 +58,20 @@
"472642d58d3d5a6d": "公式:{0}",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据",
"4b5e0ae1288a9695": "没有匹配的模型",
"4c0a929bb86ce912": "当前:{0}",
"4d2b6e53be6002e5": "缓存统计策略:{0}{1}",
"4d8c1c5b42830791": "未知",
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
"51194c3ad014fb29": "需重测",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"54e6745ff43c9c74": "排序失败",
"56627c94a9decee6": "最大输出 Token",
"58c6b0935a7216da": "打开贡献者主页失败",
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
"5aa8f5590c940829": "非缓存输入:{0}",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5c55a67935af8f45": "全部",
"5d1687a4a41883fd": "停止中...",
"5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}",
"6106f0a12583a334": "刷新失败",
@@ -92,6 +93,7 @@
"6e584e3d5ce64aa0": "保存配置",
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败",
@@ -99,14 +101,16 @@
"77c9e582e85583af": "测试失败",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
"7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新",
"80296f4aa3f4543b": "缓存读写",
"81123c56d5d880d0": "访问密钥",
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
"8151e8704a7ca89e": "没有匹配项",
"83913e71fcf7ff60": "刷新成功",
"83be9cac28873059": "Cursor 控制面账号",
"83fcfb4c1f2c1641": "获取模型",
"8672864e90417138": "最高",
"86df7ec743047234": "服务运行中",
"899add6275682210": "留空时默认 200000",
@@ -114,7 +118,6 @@
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
"8f6f8d979c981ced": "已复制",
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、prioritypriority 可用于高优先级/Fast 类场景。",
"8faa670b512b6b9b": "打开模型配置",
@@ -122,7 +125,6 @@
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "测试全部",
"91cba5c107a51892": "/ 异常",
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
"93e08803675e378b": "模型标识",
"93faf55cd25c8319": "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。",
"942ff2d88baca0c6": "检查更新中...",
@@ -135,7 +137,6 @@
"9c41b3a9e12ac994": "思考强度",
"9d2ca261281a158a": "稍后",
"9d2fb46c0ba890b9": "自定义路径",
"9dc0825fba5422e4": "加载中...",
"9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合",
"a026f37e613cf48b": "输出 Token",
"a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max",
@@ -153,6 +154,7 @@
"ac217e4d1ca410f1": "发现新版本",
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
"ae5a738238463a92": "隐藏访问密钥",
"aeb1e3a8ff46cda1": "当前还没有配置任何模型。",
"aed55419ce62f08e": "切换中...",
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
"b1c27820fec23edb": "高",
@@ -160,6 +162,7 @@
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
"b765005f69fa971f": "例如:gpt-4.1",
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
"b7ceef2fbfeb4a85": "例如:GPT-5",
"b870928f8f9a24c4": "接口端点",
"b90a8ac9c488ce46": "选择语言",
"ba3c66f90fd11725": "已识别系统代理",
@@ -172,6 +175,7 @@
"c228558cf257fc49": "删除失败",
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
"c69f5bce63b9f14c": "设置文件夹",
"c8a52b66651d294c": "退出登录失败",
"c8c14507b2d37395": "推理强度",
@@ -179,6 +183,7 @@
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
"ca00a39fcea70dc6": "启动中...",
"ca1d1059408b3837": "异常轮次:{0}",
"cc5049729a2c10f1": "测试失败,请查看原始信息",
"cd7ca5fb221e1c53": "{0}不能为空",
"cfa6c803eb3fc713": "等待浏览器登录",
"d0325067fed88e5a": "缓存命中率 {0}",
@@ -213,6 +218,8 @@
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
"ec99e5c45d648fd6": "更新失败",
"ee95057c6b0335d2": "当前出站请求使用系统代理:{0}",
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
"f1aa7326f38b4c09": "拖拽排序",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中",
+8 -12
View File
@@ -89,7 +89,7 @@ async function handleCheckForUpdates() {
if (updateViewState.footerBusy || updateViewState.footerDownloading) {
return;
}
const loadingMessageID = message.loading("检查更新中...");
const loadingMessageID = message("检查更新中...", { duration: 0 });
try {
await checkForAppUpdates();
} finally {
@@ -107,13 +107,9 @@ async function loadFooterAuthorInfo() {
}
}
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "操作失败").trim() || "操作失败",
confirmText: "确定",
showCancel: false,
});
function showActionError(title, error) {
const detail = String(error || "操作失败").trim() || "操作失败";
message(`${title}${detail}`);
}
async function handleOpenAuthorHome() {
@@ -133,7 +129,7 @@ async function handleOpenAuthorHome() {
try {
await openFooterAuthorHome();
} catch (error) {
await showActionError("打开主页失败", error);
showActionError("打开主页失败", error);
}
}
@@ -141,7 +137,7 @@ async function handleOpenUsageDocs() {
try {
await Browser.OpenURL(usageDocsURL);
} catch (error) {
await showActionError("打开使用教程失败", error);
showActionError("打开使用教程失败", error);
}
}
@@ -175,7 +171,7 @@ onUnmounted(() => {
:class="{ '!justify-center': !isWindows }"
>
<div class="center-row gap-2" style="font-family: var(--font-num);">
<img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" />
<!-- <img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" /> -->
<div>{{ title }}</div>
</div>
<div
@@ -203,7 +199,7 @@ onUnmounted(() => {
<footer
v-if="showFooter"
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] border-t border-[#242424] px-[14px] text-[12px] text-[#8f8f8f]"
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] px-[14px] text-[12px] text-[#8f8f8f]"
>
<div
v-if="proxyBadgeText"
-6
View File
@@ -1,7 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue";
import ModelConfig from "@/views/ModelConfig.vue";
import ModelEditor from "@/views/ModelEditor.vue";
const router = createRouter({
history: createWebHashHistory(),
@@ -16,11 +15,6 @@ const router = createRouter({
component: ModelConfig,
meta: { showIcon: false, title: "模型配置", directlyClose: true },
},
{
path: "/model-editor",
component: ModelEditor,
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
},
],
});
+6 -12
View File
@@ -18,12 +18,10 @@ import {
GetAppVersion,
GetFooterAuthorInfo,
InstallReadyUpdate,
GetModelEditorContext,
OpenConfigWindow,
OpenFooterAuthorHome,
OpenHistoryWindow,
OpenModelConfigWindow,
OpenModelEditorWindow,
} from "@bindings/cursor/internal/bridge/windowservice.js";
import { Call } from "@wailsio/runtime";
@@ -133,16 +131,6 @@ export function openModelConfig() {
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow());
}
export function openModelEditor(index, adapterJSON) {
return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
OpenModelEditorWindow(index, adapterJSON),
);
}
export function getModelEditorContext() {
return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
}
export function testModelAdapter(adapter) {
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
(result) => {
@@ -161,3 +149,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),
);
}
+87 -28
View File
@@ -12,11 +12,11 @@ import {
loadUserConfig,
openLogsDirectory,
openModelConfig,
openModelEditor,
saveUserConfig,
startProxyService,
stopProxyService,
testModelAdapter,
fetchModelAdapterModels,
} from "@/services/clientApi";
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
@@ -247,6 +247,7 @@ function normalizeModelAdapterTestResults(source) {
export function createEmptyModelAdapter() {
return {
id: "",
sort: 0,
displayName: "",
type: "openai",
baseURL: "",
@@ -354,6 +355,7 @@ export function normalizeModelAdapter(source) {
: "";
return {
id: asString(raw.id),
sort: asPositiveInteger(raw.sort),
displayName: asString(raw.displayName || raw.name),
type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "",
baseURL: normalizeBaseURL(raw.baseURL || raw.url),
@@ -391,7 +393,29 @@ export function normalizeModelAdapter(source) {
}
export function normalizeModelAdapters(source) {
return asArray(source).map((item) => normalizeModelAdapter(item));
return asArray(source)
.map((item, sourceIndex) => ({
adapter: normalizeModelAdapter(item),
sourceIndex,
}))
.sort((left, right) => {
const leftSort = left.adapter.sort;
const rightSort = right.adapter.sort;
if (leftSort <= 0 && rightSort <= 0) {
return left.sourceIndex - right.sourceIndex;
}
if (leftSort <= 0) {
return 1;
}
if (rightSort <= 0) {
return -1;
}
return leftSort - rightSort || left.sourceIndex - right.sourceIndex;
})
.map(({ adapter }, index) => ({
...adapter,
sort: index + 1,
}));
}
export function validateModelAdapters(source) {
@@ -399,9 +423,6 @@ export function validateModelAdapters(source) {
const seenIdentityKeys = new Set();
for (const [index, adapter] of adapters.entries()) {
const prefix = `模型 ${index + 1}`;
if (!adapter.displayName) {
return `${prefix} 的显示名称不能为空`;
}
if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) {
return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`;
}
@@ -411,15 +432,27 @@ export function validateModelAdapters(source) {
if (!adapter.apiKey) {
return `${prefix} 的访问密钥不能为空`;
}
if (!adapter.tooltipData) {
return `${prefix}悬停提示不能为空`;
if (!adapter.displayName) {
return `${prefix}显示名称不能为空`;
}
if (!adapter.modelID) {
return `${prefix} 的模型标识不能为空`;
}
if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
return `${prefix} 的上下文窗口必须为正整数`;
}
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
return `${prefix} 的推理强度仅支持 low、medium、high、xhigh、max`;
}
if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
return `${prefix} 的最大输出 Token 必须为正整数`;
}
if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
}
if (adapter.type === "openai" && adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
return `${prefix} 的最大输出 Token 必须为正整数`;
}
if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`;
}
@@ -429,29 +462,20 @@ export function validateModelAdapters(source) {
return `${prefix}${extraParamsError}`;
}
}
if (adapter.customHeadersEnabled) {
const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
if (customHeadersError) {
return `${prefix}${customHeadersError}`;
}
}
if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) {
const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON);
if (extraParamsError) {
return `${prefix}${extraParamsError}`;
}
}
if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
if (adapter.customHeadersEnabled) {
const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
if (customHeadersError) {
return `${prefix}${customHeadersError}`;
}
}
if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
return `${prefix}上下文窗口必须为正整数`;
}
if (adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
return `${prefix} 的最大输出 Token 必须为正整数`;
}
if (adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
return `${prefix} 的最大输出 Token 必须为正整数`;
if (!adapter.tooltipData) {
return `${prefix}悬停提示不能为空`;
}
if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) {
return `${prefix} 的思考预算 Token 必须为正整数`;
@@ -1143,6 +1167,13 @@ 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);
}
export async function deleteModelAdapterAt(index) {
const currentConfig = await loadPersistedUserConfig();
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
@@ -1165,6 +1196,39 @@ export async function deleteModelAdapterAt(index) {
);
}
export async function saveModelAdapterOrder(adapterIDs) {
const orderedIDs = asArray(adapterIDs)
.map((item) => asString(item))
.filter(Boolean);
const currentConfig = await loadPersistedUserConfig();
const currentAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
const adaptersByID = new Map(currentAdapters.map((adapter) => [adapter.id, adapter]));
const uniqueIDs = new Set(orderedIDs);
if (
orderedIDs.length !== currentAdapters.length
|| uniqueIDs.size !== currentAdapters.length
|| orderedIDs.some((id) => !adaptersByID.has(id))
) {
return {
ok: false,
error: "模型配置已发生变化,请刷新后重试",
};
}
const nextAdapters = orderedIDs.map((id, index) => ({
...adaptersByID.get(id),
sort: index + 1,
}));
return persistConfigPayload(
{
...currentConfig,
modelAdapters: nextAdapters,
},
{ modelAdaptersOnly: true },
);
}
function splitDisplayNameSeed(value) {
const text = asString(value);
const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/);
@@ -1308,11 +1372,6 @@ export async function openModelConfigWindow() {
await openModelConfig();
}
export async function openModelEditorWindow(index, adapter) {
const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter));
await openModelEditor(index, adapterJSON);
}
export async function checkForAppUpdates() {
await checkForUpdates();
}
+42 -4
View File
@@ -13,8 +13,15 @@ body,
width: 100vw;
overflow: hidden;
background: #191919;
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #F7F7F7;
font-family:
"PingFang-Medium",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
color: #f7f7f7;
font-size: 13px;
}
@@ -28,10 +35,41 @@ body,
scrollbar-width: thin;
scrollbar-color: rgba(100, 100, 100, 0.8) transparent;
}
:root{
:root {
color-scheme: dark;
--x-maskImage: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
black 18px,
black calc(100% - 0px),
rgba(0, 0, 0, 0) 100%
);
--x-WebkitMaskImage: linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
black 18px,
black calc(100% - 0px),
rgba(0, 0, 0, 0) 100%
);
}
.scroll-shadow {
-webkit-mask-image: var(--x-maskImage);
mask-image: var(--x-maskImage);
}
.scroll-shadow-bottom {
-webkit-mask-image: linear-gradient(
to bottom,
black 0,
black calc(100% - 16px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
black 0,
black calc(100% - 16px),
transparent 100%
);
}
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
::-webkit-scrollbar {
width: 6px;
+9 -12
View File
@@ -2,7 +2,7 @@
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import LocaleSelect from "@/components/LocaleSelect.vue";
import { showModal } from "@/composables/useModal";
import { useMessage } from "@/composables/useMessage";
import {
appState,
openModelConfigWindow,
@@ -12,30 +12,27 @@ import {
} from "@/state/appState";
import { onMounted } from "vue";
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
const message = useMessage();
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
async function handleSaveConfig() {
const result = await persistUserConfig();
if (!result.ok) {
await showActionError("保存失败", result.error);
showActionError("保存失败", result.error);
return;
}
await showModal({
title: "提示",
content: "本地配置已保存",
});
message("本地配置已保存");
}
async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
showActionError("打开失败", toUserError(error));
}
}
+18 -14
View File
@@ -3,7 +3,7 @@ import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import CursorAccountCard from "@/components/CursorAccountCard.vue";
import { showModal } from "@/composables/useModal";
import { useMessage } from "@/composables/useMessage";
import { getAdRuntime } from "@/services/clientApi";
import {
appState,
@@ -20,6 +20,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const AD_UPDATED_EVENT = "ad:updated";
const OPEN_AD_EVENT = "cursor:open-ad";
const message = useMessage();
const adRuntime = ref(null);
let unsubscribeAdUpdated = null;
@@ -79,17 +80,15 @@ function handleOpenHomeAd(slotId) {
window.dispatchEvent(new CustomEvent(OPEN_AD_EVENT, { detail: { slotId: asString(slotId) } }));
}
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
async function handleToggleService() {
const result = await toggleService();
if (!result.ok) {
await showActionError("服务操作失败", result.error);
showActionError("服务操作失败", result.error);
}
}
@@ -99,19 +98,24 @@ async function handleRefreshState() {
syncHomeMetrics(),
]);
if (serviceStateResult.status === "rejected") {
await showActionError("刷新失败", toUserError(serviceStateResult.reason));
showActionError("刷新失败", toUserError(serviceStateResult.reason));
}
}
async function handleRefreshMetrics() {
await syncHomeMetrics().catch(() => {});
const result = await syncHomeMetrics();
if (result.ok) {
message("刷新成功");
return;
}
showActionError("刷新失败", result.error);
}
async function handleOpenConfig() {
try {
await openConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
showActionError("打开失败", toUserError(error));
}
}
@@ -119,7 +123,7 @@ async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
showActionError("打开失败", toUserError(error));
}
}
@@ -136,7 +140,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-4 pt-0 text-[#e5e5e5]">
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scroll-shadow-bottom p-4 pt-0 text-[#e5e5e5]">
<HomeMetricsCard
:metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading"
@@ -148,7 +152,7 @@ onBeforeUnmount(() => {
<Card>
<div class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div class="center-row justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-sm" :class="appViewState.serviceStatusClass">
{{ appViewState.serviceStatusText }}
@@ -172,7 +176,7 @@ onBeforeUnmount(() => {
<CursorAccountCard />
<Card>
<Card class="">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-base font-medium text-white">本地配置</h2>
+225 -55
View File
@@ -1,39 +1,56 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import ContentModal from "@/components/ui/ContentModal.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import { showModal } from "@/composables/useModal";
import ModelEditor from "@/components/ModelEditor.vue";
import { useMessage } from "@/composables/useMessage";
import Sortable from "sortablejs";
import {
appState,
createEmptyModelAdapter,
deleteModelAdapterAt,
duplicateModelAdapterAt,
getModelAdapterTestResultByID,
openModelEditorWindow,
reloadUserConfig,
runModelAdapterTest,
saveModelAdapterOrder,
startModelAdapterTest,
toUserError,
} from "@/state/appState";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
const BATCH_TEST_CONCURRENCY = 10;
const message = useMessage();
const typeTabs = [
{ label: "全部", value: "all", icon: "icon-[mdi--view-grid-outline]" },
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const activeType = ref("openai");
const activeType = ref("all");
const batchTesting = ref(false);
const batchStopping = ref(false);
const batchTotal = ref(0);
const batchCompleted = ref(0);
const editorOpen = ref(false);
const editorIndex = ref(-1);
const editorAdapter = ref(null);
const editorSession = ref(0);
const modelGrid = ref(null);
const sortSaving = ref(false);
const batchActiveCalls = new Set();
let batchStopRequested = false;
let sortable = null;
const filteredAdapters = computed(() =>
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value),
const filteredAdapters = computed(() => (
activeType.value === "all"
? appState.modelAdapters
: appState.modelAdapters.filter((adapter) => adapter.type === activeType.value)
));
const filteredAdapterOrderKey = computed(() =>
filteredAdapters.value.map((adapter) => adapter.id).join("\n"),
);
const batchButtonText = computed(() => {
if (batchStopping.value) {
@@ -44,24 +61,30 @@ const batchButtonText = computed(() => {
}
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
});
const editorTitle = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
const emptyStateText = computed(() => (
activeType.value === "all"
? "当前还没有配置任何模型。"
: `当前还没有配置任何 ${typeLabel(activeType.value)} 模型。`
));
watch(
() => appState.modelAdapters,
(adapters) => {
if (adapters.some((adapter) => adapter.type === activeType.value)) {
if (
activeType.value === "all"
|| adapters.some((adapter) => adapter.type === activeType.value)
) {
return;
}
const fallback = typeTabs.find((tab) => adapters.some((adapter) => adapter.type === tab.value));
activeType.value = fallback?.value ?? "openai";
activeType.value = "all";
},
{ deep: true, immediate: true },
);
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
function showActionError(title, error) {
const detail = String(error || "服务错误").trim() || "服务错误";
message(`${title}${detail}`);
}
function maskSecret(value) {
@@ -92,41 +115,167 @@ function formatHost(value) {
}
}
async function openEditor(index = -1) {
const adapter = index >= 0
function openEditor(index = -1) {
editorIndex.value = index;
editorAdapter.value = index >= 0
? appState.modelAdapters[index]
: {
...createEmptyModelAdapter(),
type: activeType.value,
type: activeType.value === "anthropic" ? "anthropic" : "openai",
};
try {
await openModelEditorWindow(index, adapter);
} catch (error) {
await showActionError("打开失败", toUserError(error));
editorSession.value += 1;
editorOpen.value = true;
}
function closeEditor() {
if (appState.configSaving) {
return;
}
editorOpen.value = false;
}
function handleEditorSaved(adapter) {
if (activeType.value !== "all" && adapter?.type) {
activeType.value = adapter.type;
}
}
function destroySortable() {
if (!sortable) {
return;
}
sortable.destroy();
sortable = null;
}
function syncSortable() {
const element = modelGrid.value;
if (!element) {
destroySortable();
return;
}
if (!sortable || sortable.el !== element) {
destroySortable();
sortable = Sortable.create(element, {
animation: 160,
dataIdAttr: "data-model-id",
draggable: ".model-sort-item",
handle: ".model-sort-handle",
ghostClass: "opacity-40",
chosenClass: "!border-[#10AD5D]",
dragClass: "cursor-grabbing",
onEnd: (event) => {
void handleModelSort(event);
},
});
}
sortable.option(
"disabled",
sortSaving.value || appState.configSaving || batchTesting.value,
);
sortable.sort(filteredAdapters.value.map((adapter) => adapter.id), false);
}
async function restoreModelOrder(previousAdapters) {
try {
await reloadUserConfig({ modelAdaptersOnly: true });
} catch (_error) {
appState.modelAdapters = previousAdapters;
}
}
async function handleModelSort(event) {
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
const newIndex = event.newDraggableIndex ?? event.newIndex;
if (
!Number.isInteger(oldIndex)
|| !Number.isInteger(newIndex)
|| oldIndex === newIndex
) {
syncSortable();
return;
}
const reorderedTypeAdapters = filteredAdapters.value.slice();
const [movedAdapter] = reorderedTypeAdapters.splice(oldIndex, 1);
if (!movedAdapter || newIndex < 0 || newIndex > reorderedTypeAdapters.length) {
syncSortable();
return;
}
reorderedTypeAdapters.splice(newIndex, 0, movedAdapter);
const previousAdapters = appState.modelAdapters.slice();
let nextAdapters = reorderedTypeAdapters;
if (activeType.value !== "all") {
let typeIndex = 0;
nextAdapters = previousAdapters.map((adapter) => {
if (adapter.type !== activeType.value) {
return adapter;
}
const nextAdapter = reorderedTypeAdapters[typeIndex];
typeIndex += 1;
return nextAdapter;
});
}
nextAdapters = nextAdapters
.map((adapter, index) => ({
...adapter,
sort: index + 1,
}));
sortSaving.value = true;
appState.modelAdapters = nextAdapters;
try {
const result = await saveModelAdapterOrder(nextAdapters.map((adapter) => adapter.id));
if (!result.ok) {
await restoreModelOrder(previousAdapters);
showActionError("排序失败", result.error);
}
} catch (error) {
await restoreModelOrder(previousAdapters);
showActionError("排序失败", toUserError(error));
} finally {
sortSaving.value = false;
await nextTick();
syncSortable();
}
}
watch(
() => [
modelGrid.value,
activeType.value,
filteredAdapterOrderKey.value,
appState.configSaving,
batchTesting.value,
],
() => {
void nextTick().then(syncSortable);
},
{ flush: "post" },
);
async function handleDeleteModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("删除失败", "模型配置不存在,无法删除");
showActionError("删除失败", "模型配置不存在,无法删除");
return;
}
const result = await deleteModelAdapterAt(index);
if (!result.ok) {
await showActionError("删除失败", result.error);
showActionError("删除失败", result.error);
}
}
async function handleDuplicateModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("复制失败", "模型配置不存在,无法复制");
showActionError("复制失败", "模型配置不存在,无法复制");
return;
}
const result = await duplicateModelAdapterAt(index);
if (!result.ok) {
await showActionError("复制失败", result.error);
showActionError("复制失败", result.error);
}
}
@@ -211,17 +360,20 @@ async function handleTestAllModelAdapters() {
onMounted(async () => {
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
await nextTick();
syncSortable();
});
onBeforeUnmount(() => {
void stopBatchTesting();
destroySortable();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col p-4 pt-0 text-[#e5e5e5] overflow-hidden">
<div class="flex h-full min-h-0 flex-col pt-0 text-[#e5e5e5] overflow-hidden">
<div class="shrink-0 pb-4">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center justify-between gap-4 px-4">
<div class="center-row gap-2">
<button
v-for="tab in typeTabs"
@@ -240,37 +392,49 @@ onBeforeUnmount(() => {
<div class="center-row gap-2">
<Button
variant="default"
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
:disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters"
>
{{ batchButtonText }}
</Button>
<Button variant="primary" :disabled="appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<div class="min-h-0 flex-1 ">
<div v-if="filteredAdapters.length === 0"
class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] border border-dashed border-[#3a3a3a] bg-[#232323] px-4 text-sm text-[#a3a3a3]">
当前还没有配置任何 {{ typeLabel(activeType) }} 模型
class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] px-4 text-sm text-[#a3a3a3]">
{{ emptyStateText }}
</div>
<div v-else class="h-full min-h-0 overflow-y-auto pr-1">
<div class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]">
<div v-else class="h-full min-h-0 overflow-y-auto scroll-shadow-bottom p-4 pt-0">
<div
ref="modelGrid"
class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]"
>
<Card
v-for="(adapter, index) in filteredAdapters"
:key="adapter.id || `${adapter.baseURL}-${adapter.modelID}-${index}`"
class="model-sort-item group relative pb-2"
:data-model-id="adapter.id"
>
<div class="flex h-full min-h-[154px] flex-col justify-between gap-3">
<button
type="button"
class="model-sort-handle w-[30px] h-[30px] center-row justify-center absolute left-2 top-2 z-10 shrink-0 touch-none cursor-grab rounded-[6px] border border-transparent bg-transparent text-transparent opacity-0 outline-none transition-[opacity,color,border-color,background-color] focus-visible:border-[#10AD5D] focus-visible:bg-[#333333] focus-visible:text-white focus-visible:opacity-100 active:cursor-grabbing group-hover:border-[#454545] group-hover:bg-[#333333] group-hover:text-white group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
:disabled="sortSaving || appState.configSaving || batchTesting"
aria-label="拖拽排序"
title="拖拽排序"
@click.stop
>
<span class="icon-[icon-park-outline--drag] text-[20px]"></span>
</button>
<div class="flex h-[150px] flex-col justify-between gap-3">
<div class="flex flex-col gap-2.5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="min-w-0 flex-1 ">
<div class="truncate text-base font-medium text-white">{{ adapter.displayName }}</div>
<div class="mt-1 truncate text-sm text-[#8f8f8f]">{{ adapter.modelID }}</div>
<div v-if="adapter.type === 'openai'" class="mt-0.5 truncate text-xs text-[#737373]">
{{ adapter.openAIEndpoint || "/v1/responses" }}
</div>
</div>
<span
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#3f3f3f] px-[7px] py-[4px] text-[11px] font-medium text-[#cfcfcf]"
@@ -281,17 +445,6 @@ onBeforeUnmount(() => {
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-[#a3a3a3]">
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">Host</div>
<div class="mt-1 truncate text-[#d4d4d4]" :title="adapter.baseURL">{{ formatHost(adapter.baseURL) }}</div>
</div>
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">API Key</div>
<div class="mt-1 truncate text-[#d4d4d4]">{{ maskSecret(adapter.apiKey) }}</div>
</div>
</div>
<ModelAdapterTestCard
compact
title="测试"
@@ -300,17 +453,17 @@ onBeforeUnmount(() => {
/>
</div>
<div class="center-row flex-wrap justify-end gap-2 border-t border-[#343434] pt-3">
<div class="center-row flex-wrap justify-end gap-2 pt-0">
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)"
:disabled="sortSaving || appState.configSaving || batchTesting || isAdapterTesting(adapter)"
@click="handleTestModelAdapter(adapter)"
>
{{ isAdapterTesting(adapter) ? "测试中..." : "测试" }}
</Button>
<Button variant="default" :disabled="appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="appState.configSaving"
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="sortSaving || appState.configSaving"
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
</div>
</div>
@@ -319,4 +472,21 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<ContentModal
:open="editorOpen"
:title="editorTitle"
size="xl"
:close-disabled="appState.configSaving"
@close="closeEditor"
>
<ModelEditor
v-if="editorOpen && editorAdapter"
:key="editorSession"
:index="editorIndex"
:adapter="editorAdapter"
@saved="handleEditorSaved"
@close="closeEditor"
/>
</ContentModal>
</template>
+5
View File
@@ -1587,6 +1587,11 @@ semver@^6.3.1:
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
sortablejs@^1.15.7:
version "1.15.7"
resolved "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz#83a0bddc472117ee328dea20b2e6f490fed20f86"
integrity sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+3 -3
View File
@@ -215,9 +215,9 @@ func Run(resources EmbeddedResources) error {
mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: appName,
Width: 700,
Height: 520,
MinWidth: 640,
MinHeight: 480,
Height: 530,
MinWidth: 700,
MinHeight: 530,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: "/",
+1 -1
View File
@@ -839,7 +839,7 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
}
}
if choice.FinishReason != nil {
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
if err := flushTaggedContentTail(); err != nil {
return fail(err)
}
@@ -0,0 +1,95 @@
package modeladapter
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/event-stream")
chunks := []string{
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":"first"},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":" second"},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"Ls","arguments":""}}]},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"path\":"}}]},"finish_reason":""}]}`,
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"/tmp\"}"}}]},"finish_reason":"tool_calls"}]}`,
`{"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":7}}`,
}
for _, chunk := range chunks {
_, _ = fmt.Fprintf(writer, "data: %s\n\n", chunk)
}
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
}))
defer server.Close()
adapter := &OpenAIAdapter{client: server.Client()}
events := make([]ModelEvent, 0, 8)
err := adapter.Stream(context.Background(), StreamRequest{
RequestID: "request-1",
RunID: "run-1",
ModelCallID: "model-call-1",
BaseURL: server.URL,
APIKey: "test-key",
ProviderModelID: "deepseek-v4-flash",
OpenAIEndpoint: "/v1/chat/completions",
Messages: []Message{{Role: "user", Content: "list files"}},
MaxTokens: 128,
RequestKnobs: map[string]any{},
}, func(event ModelEvent) error {
events = append(events, event)
return nil
})
if err != nil {
t.Fatalf("stream failed: %v", err)
}
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 2)
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindToolLikeCompleted, 1)
assertOpenAIEventKindCount(t, events, ModelEventKindTurnFinished, 1)
toolEvent := firstOpenAIEventForTest(events, ModelEventKindToolLikeCompleted)
if toolEvent == nil || toolEvent.ToolInvocation == nil {
t.Fatalf("completed tool invocation missing: %#v", toolEvent)
}
if toolEvent.ToolInvocation.ToolName != "Ls" {
t.Fatalf("tool name = %q, want Ls", toolEvent.ToolInvocation.ToolName)
}
if got := string(toolEvent.ToolInvocation.ArgsJSON); got != `{"path":"/tmp"}` {
t.Fatalf("tool args = %q, want %q", got, `{"path":"/tmp"}`)
}
finished := firstOpenAIEventForTest(events, ModelEventKindTurnFinished)
if finished == nil || finished.FinishReason != "tool_calls" {
t.Fatalf("finish reason = %q, want tool_calls", finished.FinishReason)
}
if finished.InputTokens != 12 || finished.OutputTokens != 7 {
t.Fatalf("usage = input:%d output:%d, want input:12 output:7", finished.InputTokens, finished.OutputTokens)
}
}
func assertOpenAIEventKindCount(t *testing.T, events []ModelEvent, kind ModelEventKind, want int) {
t.Helper()
got := 0
for _, event := range events {
if event.Kind == kind {
got++
}
}
if got != want {
t.Fatalf("event kind %s count = %d, want %d; events=%#v", kind, got, want, events)
}
}
func firstOpenAIEventForTest(events []ModelEvent, kind ModelEventKind) *ModelEvent {
for index := range events {
if events[index].Kind == kind {
return &events[index]
}
}
return nil
}
+40 -5
View File
@@ -94,6 +94,13 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
// Keep the latest live UI state ahead of an immediate client abort. Blob writes are
// ordered before this snapshot; acknowledgements still gate terminal completion.
if completion == nil {
if err := service.publishPendingCheckpoint(stream); err != nil {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish pending checkpoint: %w", err))
}
}
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
@@ -106,6 +113,31 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
return nil
}
func (service *Service) publishPendingCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil || pending.Published {
stream.mu.Unlock()
return nil
}
pending.Published = true
state := pending.State
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
stream.mu.Lock()
if stream.PendingCheckpoint == pending {
pending.Published = false
}
stream.mu.Unlock()
return err
}
return nil
}
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil {
return false
@@ -176,15 +208,18 @@ func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
stream.PendingCheckpoint = nil
state := pending.State
completion := clonePendingTurnCompletion(pending.Completion)
published := pending.Published
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)
if !published {
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
}
return err
}
if completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
@@ -8,26 +8,34 @@ import (
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(t *testing.T) {
func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(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))
if len(events) != len(projection.Blobs)+1 {
t.Fatalf("events before ACK = %d, want %d Blob writes and one checkpoint", len(events), len(projection.Blobs))
}
for _, event := range events {
for _, event := range events[:len(projection.Blobs)] {
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
}
}
if checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate(); checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event before ACK = %#v, want one Blob-backed turn", events[len(events)-1].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)
checkpointCount := 0
for _, event := range events {
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointCount++
}
}
if checkpointCount != 1 {
t.Fatalf("checkpoints after ACK = %d, want 1", checkpointCount)
}
}
@@ -40,6 +48,12 @@ func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testin
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
eventsBeforeACK := readCheckpointTestEvents(t, service, stream)
for _, event := range eventsBeforeACK {
if event.Message.GetConversationCheckpointUpdate() != nil || event.Message.GetInteractionUpdate().GetTurnEnded() != nil || event.End {
t.Fatalf("event before ACK = %#v, want only Blob writes", event)
}
}
acknowledgeCheckpointBlobs(t, service, stream)
events := readCheckpointTestEvents(t, service, stream)
@@ -84,11 +98,21 @@ func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
}
}
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
eventsBeforeCancel := readCheckpointTestEvents(t, service, stream)
checkpointBeforeCancel := 0
for _, event := range eventsBeforeCancel {
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointBeforeCancel++
}
}
if checkpointBeforeCancel != 1 {
t.Fatalf("checkpoints before cancel = %d, want 1", checkpointBeforeCancel)
}
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
@@ -114,16 +138,19 @@ func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, canceledEnd bool
checkpointCount := 0
var canceledEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
if event.Message.GetConversationCheckpointUpdate() != nil {
checkpointCount++
}
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)
if checkpointCount != 1 || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
}
}
+12
View File
@@ -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)
}
+90 -106
View File
@@ -355,6 +355,7 @@ const (
cancelReplayPolicyDropTurn = "drop_turn"
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
cancelReplayPolicyKeepStableInput = "keep_stable_input"
cancelReplayPolicyKeepInterrupted = "keep_interrupted_output"
)
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
@@ -370,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
}
@@ -450,6 +457,8 @@ func normalizeCancelReplayPolicy(policy string, reason string) string {
return cancelReplayPolicyDropUnstarted
case cancelReplayPolicyKeepStableInput:
return cancelReplayPolicyKeepStableInput
case cancelReplayPolicyKeepInterrupted:
return cancelReplayPolicyKeepInterrupted
default:
return cancelReplayPolicyForReason(reason)
}
@@ -612,13 +621,29 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
turnIDs := make([][]byte, 0, len(order))
logicalTurns := make([][]HistoryEntry, 0, len(order))
for _, turnSeq := range order {
entries := grouped[turnSeq]
if checkpointTurnHasUserMessage(entries) {
logicalTurns = append(logicalTurns, append([]HistoryEntry(nil), entries...))
continue
}
if len(logicalTurns) == 0 {
continue
}
last := len(logicalTurns) - 1
logicalTurns[last] = append(logicalTurns[last], entries...)
}
turnIDs := make([][]byte, 0, len(logicalTurns))
for _, entries := range logicalTurns {
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
if err != nil {
return nil, err
}
var userMessageID []byte
var turnRequestID string
stepIDs := make([][]byte, 0, len(entries))
steps := make([]*agentv1.ConversationStep, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
@@ -645,59 +670,48 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
toolCall := &agentv1.ToolCall{}
toolCallID := strings.TrimSpace(payload.ToolCallID)
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
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)
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
@@ -706,22 +720,19 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
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
}
toolCallID := strings.TrimSpace(payload.ToolCallID)
if toolCallID != "" {
delete(openToolCalls, toolCallID)
}
if _, ok := seenToolCalls[toolCallID]; ok {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if len(payload.ToolCall) == 0 {
continue
@@ -730,21 +741,22 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
steps = append(steps, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
}
if len(userMessageID) == 0 && len(stepIDs) == 0 {
if len(userMessageID) == 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,
@@ -765,6 +777,32 @@ func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpoin
return turnIDs, nil
}
func checkpointTurnHasUserMessage(entries []HistoryEntry) bool {
for _, entry := range entries {
if strings.TrimSpace(entry.Kind) == "user_message" {
return true
}
}
return false
}
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 {
@@ -1209,7 +1247,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
@@ -1218,60 +1256,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
@@ -1282,7 +1266,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
}
@@ -1299,7 +1283,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
}
}
@@ -1,7 +1,9 @@
package forwarder
import (
"bytes"
"crypto/sha256"
"encoding/json"
"strings"
"testing"
@@ -9,6 +11,7 @@ import (
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
promptengine "cursor/internal/backend/agent/prompt"
)
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
@@ -84,6 +87,94 @@ func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
}
}
func TestProjectCheckpointProjectionMergesResumeActivityIntoPreviousUserTurn(t *testing.T) {
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
Text: "original question",
MessageId: "message-1",
})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
firstAnswer := newAssistantTextEntry(1, "request-1", "before resume", "", "")
firstAnswer.Seq = 2
resumedAnswer := newAssistantTextEntry(2, "request-resume", "after resume", "", "")
resumedAnswer.Seq = 3
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 3,
NextEntrySeq: 4,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
firstAnswer,
resumedAnswer,
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 1 {
t.Fatalf("checkpoint turns = %d, want one logical user turn", len(projection.State.GetTurns()))
}
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")
}
if len(agentTurn.GetUserMessage()) == 0 {
t.Fatal("checkpoint turn lost the original user message Blob id")
}
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
t.Fatal("checkpoint turn references a missing user message Blob")
}
if agentTurn.GetRequestId() != "request-1" {
t.Fatalf("checkpoint request id = %q, want original request", agentTurn.GetRequestId())
}
steps := checkpointProjectionSteps(t, projection)
if len(steps) != 2 || steps[0].GetAssistantMessage().GetText() != "before resume" || steps[1].GetAssistantMessage().GetText() != "after resume" {
t.Fatalf("checkpoint steps did not preserve resumed activity: %#v", steps)
}
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
if err != nil {
t.Fatalf("decode root prompt replay: %v", err)
}
if len(replay) != 3 || replay[0].Role != "user" || replay[1].Content != "before resume" || replay[2].Content != "after resume" {
t.Fatalf("checkpoint turn merge changed model replay: %#v", replay)
}
}
func TestProjectCheckpointProjectionOmitsActivityWithoutAnyUserMessage(t *testing.T) {
conversation := &ConversationFile{
ConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
Entries: []HistoryEntry{
newAssistantTextEntry(1, "request-resume", "orphaned resume output", "", ""),
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if len(projection.State.GetTurns()) != 0 || len(projection.Blobs) != 0 {
t.Fatalf("checkpoint emitted a turn without a user message: %#v", projection)
}
}
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
if err != nil {
@@ -138,3 +229,242 @@ func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *te
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{
testCheckpointUserEntry(t),
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{
testCheckpointUserEntry(t),
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
}
+117 -13
View File
@@ -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
}
@@ -869,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)
@@ -1643,6 +1736,13 @@ func (service *Service) handleToolInvocation(stream *ActiveStream, invocation ru
stream.ToolInvocationCount++
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if !isKnownToolName(trimmedToolName) {
displayToolName := trimmedToolName
if displayToolName == "" {
displayToolName = "<empty>"
}
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("Model hallucination: attempted to invoke a nonexistent tool: %s", displayToolName))
}
if !isToolAllowedInMode(mode, subagentTypeName, trimmedToolName) {
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("tool invocation is not enabled in mode %s: %s", mode.String(), invocation.ToolName))
}
@@ -2431,6 +2531,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,
@@ -2440,13 +2550,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。
@@ -181,6 +181,25 @@ func supportedToolNamesForMode(mode agentv1.AgentMode) map[string]struct{} {
}
}
func isKnownToolName(toolName string) bool {
trimmedToolName := strings.TrimSpace(toolName)
if trimmedToolName == "" {
return false
}
for _, supported := range []map[string]struct{}{
agentModeToolNames,
askModeToolNames,
planModeToolNames,
debugModeToolNames,
multitaskModeToolNames,
} {
if _, ok := supported[trimmedToolName]; ok {
return true
}
}
return false
}
func isToolAllowedInMode(mode agentv1.AgentMode, subagentTypeName string, toolName string) bool {
trimmedToolName := strings.TrimSpace(toolName)
if trimmedToolName == "" {
+2
View File
@@ -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"`
@@ -227,6 +228,7 @@ type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure
Required map[string]struct{}
Completion *pendingTurnCompletion
Published bool
}
type PendingCompaction struct {
@@ -16,6 +16,7 @@ func (store *Store) LegacyRuntimeSnapshot(ctx context.Context) (legacyruntime.Ru
for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName,
Type: item.Type,
BaseURL: item.BaseURL,
@@ -147,6 +147,7 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
ID: item.ID,
Sort: item.Sort,
DisplayName: item.DisplayName,
Type: item.Type,
BaseURL: item.BaseURL,
+24
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
@@ -21,6 +22,7 @@ const (
type ModelAdapterConfig struct {
ID string `json:"id,omitempty" yaml:"-"`
Sort int `json:"sort" yaml:"sort"`
DisplayName string `json:"displayName" yaml:"displayName"`
Type string `json:"type" yaml:"type"`
BaseURL string `json:"baseURL" yaml:"baseURL"`
@@ -104,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
}
nextType := normalizeModelAdapterType(item.Type)
next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName),
Type: nextType,
BaseURL: baseURL,
@@ -164,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next)
}
normalizeModelAdapterSorts(normalized)
return normalized, nil
}
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
left := adapters[leftIndex].Sort
right := adapters[rightIndex].Sort
switch {
case left <= 0 && right <= 0:
return false
case left <= 0:
return false
case right <= 0:
return true
default:
return left < right
}
})
for index := range adapters {
adapters[index].Sort = index + 1
}
}
func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value)
if text == "" {
@@ -0,0 +1,60 @@
package config
import "testing"
func testModelAdapter(displayName string, sortValue int) ModelAdapterConfig {
return ModelAdapterConfig{
Sort: sortValue,
DisplayName: displayName,
Type: "openai",
BaseURL: "https://api.example.com/v1",
APIKey: "test-key",
TooltipData: displayName,
ModelID: displayName,
ReasoningEffort: "medium",
OpenAIEndpoint: "/v1/responses",
}
}
func TestNormalizeModelAdapterConfigsPreservesLegacyArrayOrder(t *testing.T) {
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
testModelAdapter("first", 0),
testModelAdapter("second", 0),
testModelAdapter("third", 0),
})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
for index, expectedName := range []string{"first", "second", "third"} {
if adapters[index].DisplayName != expectedName {
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
}
if adapters[index].Sort != index+1 {
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
}
}
}
func TestNormalizeModelAdapterConfigsUsesStableExplicitSort(t *testing.T) {
adapters, err := NormalizeModelAdapterConfigs([]ModelAdapterConfig{
testModelAdapter("legacy", 0),
testModelAdapter("third", 30),
testModelAdapter("first", 10),
testModelAdapter("second-a", 20),
testModelAdapter("second-b", 20),
})
if err != nil {
t.Fatalf("NormalizeModelAdapterConfigs returned error: %v", err)
}
expectedNames := []string{"first", "second-a", "second-b", "third", "legacy"}
for index, expectedName := range expectedNames {
if adapters[index].DisplayName != expectedName {
t.Fatalf("adapter %d = %q, want %q", index, adapters[index].DisplayName, expectedName)
}
if adapters[index].Sort != index+1 {
t.Fatalf("adapter %d sort = %d, want %d", index, adapters[index].Sort, index+1)
}
}
}
+11
View File
@@ -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()
+2 -101
View File
@@ -15,19 +15,11 @@ import (
"github.com/wailsapp/wails/v3/pkg/events"
)
// modelEditorContext 保存当前模型编辑器窗口的初始化上下文。
type modelEditorContext struct {
Index int `json:"index"`
AdapterJSON string `json:"adapterJSON"`
}
// WindowService 定义了当前模块中的 WindowService 类型。
type WindowService struct {
app *application.App
updater *updater.Manager
modelConfigWindow *application.WebviewWindow
modelEditorWindow *application.WebviewWindow
editorCtx *modelEditorContext
mu sync.RWMutex
}
@@ -102,8 +94,8 @@ func (s *WindowService) OpenModelConfigWindow() {
Title: "模型配置",
Width: 980,
Height: 700,
MinWidth: 820,
MinHeight: 560,
MinWidth: 980,
MinHeight: 700,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: "/#/model-config",
@@ -144,97 +136,6 @@ func (s *WindowService) OpenModelConfigWindow() {
s.modelConfigWindow = win
}
// OpenModelEditorWindow 打开模型编辑器独立窗口。
// index < 0 表示新增,>= 0 表示编辑对应索引的适配器。
// adapterJSON 为编辑器初始数据的 JSON 字符串。
func (s *WindowService) OpenModelEditorWindow(index int, adapterJSON string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.app == nil {
return
}
s.editorCtx = &modelEditorContext{
Index: index,
AdapterJSON: adapterJSON,
}
if s.modelEditorWindow != nil {
s.modelEditorWindow.Show()
s.modelEditorWindow.Focus()
return
}
title := "新增模型配置"
if index >= 0 {
title = "编辑模型配置"
}
win := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: title,
Width: 840,
Height: 680,
MinWidth: 740,
MinHeight: 600,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: fmt.Sprintf("/#/model-editor?index=%d", index),
Hidden: false,
HideOnEscape: false,
MinimiseButtonState: application.ButtonEnabled,
MaximiseButtonState: application.ButtonEnabled,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255},
Mac: application.MacWindow{
Backdrop: application.MacBackdropLiquidGlass,
DisableShadow: false,
TitleBar: application.MacTitleBar{
AppearsTransparent: true,
Hide: false,
HideTitle: true,
FullSizeContent: true,
UseToolbar: false,
HideToolbarSeparator: true,
},
WebviewPreferences: application.MacWebviewPreferences{
FullscreenEnabled: u.False,
TextInteractionEnabled: u.True,
AllowsBackForwardNavigationGestures: u.False,
},
},
Windows: application.WindowsWindow{
HiddenOnTaskbar: false,
},
})
win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
s.mu.Lock()
defer s.mu.Unlock()
s.modelEditorWindow = nil
s.editorCtx = nil
})
s.modelEditorWindow = win
}
// GetModelEditorContext 返回当前编辑器窗口的初始化上下文。
func (s *WindowService) GetModelEditorContext() map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
if s.editorCtx == nil {
return map[string]any{
"index": -1,
"adapterJSON": "{}",
}
}
return map[string]any{
"index": s.editorCtx.Index,
"adapterJSON": s.editorCtx.AdapterJSON,
}
}
// OpenHistoryWindow 用于处理与 OpenHistoryWindow 相关的逻辑。
func (s *WindowService) OpenHistoryWindow() {
_ = os.MkdirAll(client.ResolveLogsRootPath(), 0o755)
+303
View File
@@ -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 1openai 忽略 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")
}
})
}
}
+25
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -36,6 +37,8 @@ const (
// ModelAdapterConfig 定义了当前模块中的 ModelAdapterConfig 类型。
type ModelAdapterConfig struct {
ID string `json:"id,omitempty"`
// Sort 表示模型渠道的展示顺序。
Sort int `json:"sort"`
// DisplayName 表示当前声明中的 DisplayName。
DisplayName string `json:"displayName"`
// Type 表示当前声明中的 Type。
@@ -103,6 +106,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
return nil, err
}
next := ModelAdapterConfig{
Sort: item.Sort,
DisplayName: strings.TrimSpace(item.DisplayName),
Type: normalizeModelAdapterType(item.Type),
BaseURL: baseURL,
@@ -163,9 +167,30 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
seenChannelIDs[next.ID] = struct{}{}
normalized = append(normalized, next)
}
normalizeModelAdapterSorts(normalized)
return normalized, nil
}
func normalizeModelAdapterSorts(adapters []ModelAdapterConfig) {
sort.SliceStable(adapters, func(leftIndex, rightIndex int) bool {
left := adapters[leftIndex].Sort
right := adapters[rightIndex].Sort
switch {
case left <= 0 && right <= 0:
return false
case left <= 0:
return false
case right <= 0:
return true
default:
return left < right
}
})
for index := range adapters {
adapters[index].Sort = index + 1
}
}
func validateJSONMap(value string, fieldName string) error {
text := strings.TrimSpace(value)
if text == "" {
+2500 -739
View File
File diff suppressed because it is too large Load Diff
+10930 -4348
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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
View File
@@ -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)
+72
View File
@@ -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
)
+73 -20
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+809
View File
@@ -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 -4
View File
@@ -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