mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c96cb0a110 | ||
|
|
4db2061611 | ||
|
|
eafede22e3 | ||
|
|
3e7a15017d | ||
|
|
a3ec2a0dfc | ||
|
|
38472a3c63 | ||
|
|
2b8d1c9e3d | ||
|
|
7a67e76617 | ||
|
|
08ce12aa55 | ||
|
|
8f8d28880d | ||
|
|
988ba63d40 | ||
|
|
edd59dc684 | ||
|
|
da5fa34a4d | ||
|
|
4bd2359282 | ||
|
|
7838ffc6a2 | ||
|
|
ee30775be1 | ||
|
|
80a5093aa9 | ||
|
|
b9742b1667 | ||
|
|
7d3e74be59 | ||
|
|
1285bf9d62 | ||
|
|
7a724595eb | ||
|
|
00aec40e50 | ||
|
|
c10a2d475d | ||
|
|
4864d3675b | ||
|
|
3f95318a49 | ||
|
|
9373e57ebf | ||
|
|
f1992b0cfe | ||
|
|
67a9c27931 | ||
|
|
3cf8bdbc3c | ||
|
|
684953a80b | ||
|
|
297b56aed0 | ||
|
|
2393df1cb8 | ||
|
|
7d622dd039 | ||
|
|
1a7a20c519 | ||
|
|
85a43115c7 | ||
|
|
b475166ba8 |
@@ -1,356 +0,0 @@
|
||||
---
|
||||
name: coding-guidance
|
||||
description: 本地模式实现指南
|
||||
---
|
||||
当用户在处理本地模式的时候,使用此指南
|
||||
|
||||
先遵守这个约束:
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
|
||||
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
|
||||
|
||||
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`:
|
||||
|
||||
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
|
||||
- 需要确认当前到底是哪一个 app 副本在运行
|
||||
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
|
||||
- 需要对照已安装客户端行为与本仓库实现差异
|
||||
|
||||
本地模式协议需要优先核对这些文件:
|
||||
- proto/agent_v1.proto
|
||||
- proto/aiserver_v1.proto
|
||||
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
||||
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
|
||||
|
||||
## 可选抓包调试工具
|
||||
|
||||
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
|
||||
|
||||
- `agent.v1.AgentService/RunSSE`
|
||||
- `aiserver.v1.BidiService/BidiAppend`
|
||||
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
|
||||
- 同一 `request_id` 对应的上下行消息
|
||||
|
||||
开发者启动工具后,需要自行完成以下配置:
|
||||
|
||||
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。
|
||||
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
|
||||
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||
|
||||
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
|
||||
|
||||
## Cursor 客户端格式化快照
|
||||
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
||||
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
|
||||
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
|
||||
- 常用格式化路径:
|
||||
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
|
||||
|
||||
## 本仓库已固定的会话承接规则
|
||||
|
||||
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
|
||||
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq`、`next_entry_seq`、`context_version`、`current_todos`、`current_plans`、`latest_request_prefix`、`last_provider_call`。
|
||||
- `context.json.items` 保存 append-only 的语义历史 entries;provider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
|
||||
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash,resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
|
||||
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
|
||||
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
|
||||
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
|
||||
- `summary.json`、`replay.json`、`runtime.json`、`request.json`、`conversation.json`、`entries.jsonl`、`turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
|
||||
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json` 的 `next_entry_seq`、`next_turn_seq`、`context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
|
||||
|
||||
# 已确认结论
|
||||
|
||||
## 1. `AgentServerMessage` 不是统一都要“回复完成”
|
||||
|
||||
要按 `oneof message` 分类看:
|
||||
|
||||
- `exec_server_message`
|
||||
- 这是服务端发给客户端的“执行请求”。
|
||||
- 客户端需要显式回 `ExecClientMessage`。
|
||||
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
|
||||
- `stream_close`
|
||||
- `throw`
|
||||
- `heartbeat`
|
||||
- `interaction_query`
|
||||
- 这是服务端发给客户端的“交互请求”。
|
||||
- 客户端需要显式回 `InteractionResponse`。
|
||||
- `interaction_update`
|
||||
- 这是展示/状态更新消息,通常不需要客户端回包。
|
||||
- `conversation_checkpoint_update`
|
||||
- 这是 checkpoint 同步消息,通常不需要客户端回包。
|
||||
- `kv_server_message`
|
||||
- 这是 KV 同步消息,通常不需要客户端回包。
|
||||
- `exec_server_control_message`
|
||||
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
|
||||
|
||||
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
|
||||
|
||||
在 `cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`:
|
||||
|
||||
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
|
||||
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
|
||||
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
|
||||
|
||||
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`。
|
||||
|
||||
## 2.1 更具体的客户端侧结论
|
||||
|
||||
从 `cursor-always-local/dist/main.js` 里能直接确认:
|
||||
|
||||
- `AgentServerMessage` 的下行类型里有:
|
||||
- `interaction_update`
|
||||
- `exec_server_message`
|
||||
- `exec_server_control_message`
|
||||
- `conversation_checkpoint_update`
|
||||
- `interaction_query`
|
||||
- `AgentClientMessage` 的上行类型里有:
|
||||
- `run_request`
|
||||
- `exec_client_message`
|
||||
- `exec_client_control_message`
|
||||
- `interaction_response`
|
||||
|
||||
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
|
||||
|
||||
- `exec_server_message`
|
||||
-> 客户端执行本地工具
|
||||
-> 产出 `exec_client_message`
|
||||
-> 以及可选 `exec_client_control_message`
|
||||
- `interaction_query`
|
||||
-> 客户端展示或处理交互
|
||||
-> 产出 `interaction_response`
|
||||
- 其他下行消息
|
||||
-> 一般只更新 UI / checkpoint /流状态
|
||||
-> 不会自然地产生一个“完成 ack”
|
||||
|
||||
## 2.2 `exec_server_message` 常见的客户端回包形态
|
||||
|
||||
客户端协议模型里已确认这些回包类型:
|
||||
|
||||
- `ExecClientMessage`
|
||||
- 正常结果面
|
||||
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream` 等
|
||||
- `ExecClientControlMessage`
|
||||
- 控制面
|
||||
- 包括:
|
||||
- `stream_close`
|
||||
- `throw`
|
||||
- `heartbeat`
|
||||
|
||||
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`:
|
||||
|
||||
- 有些工具只回一次结果面消息
|
||||
- shell 之类的流式工具会混合回:
|
||||
- 多次 `shell_stream`
|
||||
- 以及控制消息(例如 `stream_close` / `heartbeat`)
|
||||
|
||||
## 2.3 `exec_server_message` 的完整回包形态
|
||||
|
||||
事实依据:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `ExecServerMessage.oneof message`
|
||||
- `ExecClientMessage.oneof message`
|
||||
- `ExecClientControlMessage.oneof message`
|
||||
- `cursor-always-local/dist/main.js`
|
||||
- bundle 内含同名 proto 模型
|
||||
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
|
||||
|
||||
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
|
||||
|
||||
`ExecServerMessage` 的 `message` 分支与 `ExecClientMessage` 的 `message` 分支是一一对应的:
|
||||
|
||||
- `shell_args`
|
||||
-> `shell_result`
|
||||
- `write_args`
|
||||
-> `write_result`
|
||||
- `delete_args`
|
||||
-> `delete_result`
|
||||
- `grep_args`
|
||||
-> `grep_result`
|
||||
- `read_args`
|
||||
-> `read_result`
|
||||
- `ls_args`
|
||||
-> `ls_result`
|
||||
- `diagnostics_args`
|
||||
-> `diagnostics_result`
|
||||
- `request_context_args`
|
||||
-> `request_context_result`
|
||||
- `mcp_args`
|
||||
-> `mcp_result`
|
||||
- `shell_stream_args`
|
||||
-> `shell_stream`
|
||||
- `background_shell_spawn_args`
|
||||
-> `background_shell_spawn_result`
|
||||
- `list_mcp_resources_exec_args`
|
||||
-> `list_mcp_resources_exec_result`
|
||||
- `read_mcp_resource_exec_args`
|
||||
-> `read_mcp_resource_exec_result`
|
||||
- `fetch_args`
|
||||
-> `fetch_result`
|
||||
- `record_screen_args`
|
||||
-> `record_screen_result`
|
||||
- `computer_use_args`
|
||||
-> `computer_use_result`
|
||||
- `write_shell_stdin_args`
|
||||
-> `write_shell_stdin_result`
|
||||
- `execute_hook_args`
|
||||
-> `execute_hook_result`
|
||||
- `subagent_args`
|
||||
-> `subagent_result`
|
||||
|
||||
所有这些结果面回包都带:
|
||||
|
||||
- `id`
|
||||
- `exec_id`
|
||||
|
||||
服务端匹配时通常优先用:
|
||||
|
||||
1. `exec_id`
|
||||
2. `id`
|
||||
|
||||
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
|
||||
|
||||
除了结果面回包外,客户端还可能回控制面消息:
|
||||
|
||||
- `stream_close`
|
||||
- 表示当前 exec 流已关闭
|
||||
- 只有 `id`
|
||||
- `throw`
|
||||
- 表示执行异常
|
||||
- 只有 `id` + `error` + 可选 `stack_trace`
|
||||
- `heartbeat`
|
||||
- 表示执行过程中的心跳
|
||||
- 只有 `id`
|
||||
|
||||
### 关键理解
|
||||
|
||||
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
|
||||
- 它是跨 exec 通用的控制面回包
|
||||
- 因此调查时必须同时看两类上行:
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
|
||||
### 调查规则
|
||||
|
||||
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
|
||||
|
||||
- 收到对应的 `ExecClientMessage`
|
||||
- 或收到 `ExecClientControlMessage.throw`
|
||||
- 对流式 exec,还要看:
|
||||
- 是否有多次增量 `ExecClientMessage`
|
||||
- 是否最终有 `stream_close`
|
||||
|
||||
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
|
||||
|
||||
排查时优先搜索这些关键字:
|
||||
|
||||
## 3. forwarder 状态机实现规则
|
||||
|
||||
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
|
||||
|
||||
### 3.1 resume 必须按 provider pass 隔离
|
||||
|
||||
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
|
||||
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
|
||||
- resume 请求必须带来源 pass,至少要能区分:
|
||||
- 当前 pass 的结果触发的合法续跑
|
||||
- 上一轮工具终态晚到造成的陈旧 resume
|
||||
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
|
||||
|
||||
### 3.2 工具晚到是常态,只能影响所属 pass
|
||||
|
||||
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
|
||||
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
|
||||
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
|
||||
|
||||
### 3.3 pending exec 必须严格按 id 匹配
|
||||
|
||||
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
|
||||
- `exec_id`
|
||||
- `message_id`
|
||||
进行匹配。
|
||||
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
|
||||
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
|
||||
- 优先看 `RecentCompletedExecs` 做幂等忽略
|
||||
- 不要把它重新落到当前轮的 pending 上
|
||||
|
||||
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
|
||||
|
||||
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
|
||||
|
||||
- 同一个 `request_id` 在 `[DONE]` 后又出现新的 `model_call_id`
|
||||
- `turns/<n+1>/request.json` 与 `turns/<n>/request.json` messages 几乎完全相同
|
||||
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
|
||||
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
|
||||
|
||||
优先核对:
|
||||
|
||||
- `ProviderPassCount`
|
||||
- resume 请求的来源 pass
|
||||
- `PendingExec.ProviderPass`
|
||||
- `selectPendingExec` 是否存在跨轮误匹配
|
||||
|
||||
- `startYieldingInputsToTheServer`
|
||||
- `bidiAppend({requestId:A,appendSeqno`
|
||||
- `ExecServerMessage`
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
- `InteractionQuery`
|
||||
- `InteractionResponse`
|
||||
|
||||
## 3. 对本地模式最重要的协议理解
|
||||
|
||||
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
|
||||
- 如果客户端不回对应结果,服务端 pending 不会收口
|
||||
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
|
||||
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
|
||||
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
|
||||
- 一般不要求客户端再回一个“完成”消息
|
||||
|
||||
## 4. 调查本地模式时的优先顺序
|
||||
|
||||
1. 先确认收到的 `AgentServerMessage` 是哪一类
|
||||
2. 如果是 `exec_server_message`
|
||||
- 查客户端是否回了 `ExecClientMessage`
|
||||
- 查是否只回了 `stream_close` 但没有真正结果
|
||||
- 查 `exec_id` / `id` 是否匹配
|
||||
3. 如果是 `interaction_query`
|
||||
- 查客户端是否回了 `InteractionResponse`
|
||||
4. 如果是 `conversation_checkpoint_update`
|
||||
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
|
||||
- 不要误以为它本身需要回 ack
|
||||
|
||||
## 5. 对服务端实现的直接要求
|
||||
|
||||
- 服务端必须区分“请求型下行”和“通知型下行”
|
||||
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
|
||||
- 对请求型消息,必须在本地状态机里维护 pending:
|
||||
- `PendingExec`
|
||||
- `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 仍遵守独立过滤规则。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "本地模式实现指南"
|
||||
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
|
||||
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: cursor-app-formatted
|
||||
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
|
||||
---
|
||||
|
||||
# Cursor App Formatted Snapshot
|
||||
|
||||
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
|
||||
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
|
||||
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
|
||||
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
|
||||
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
|
||||
|
||||
## Preferred Investigation Flow
|
||||
|
||||
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
|
||||
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
|
||||
3. Prefer stable formatted paths for line references and control-flow reading:
|
||||
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
|
||||
|
||||
## Git Ignore Rule
|
||||
|
||||
Ensure `.gitignore` contains:
|
||||
|
||||
```gitignore
|
||||
.cursor-app-formatted/
|
||||
```
|
||||
|
||||
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
|
||||
|
||||
## Snapshot Generation Workflow
|
||||
|
||||
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
|
||||
SNAPSHOT=.cursor-app-formatted
|
||||
SOURCE=/Applications/Cursor.app/Contents/Resources/app
|
||||
|
||||
rm -rf "$SNAPSHOT"
|
||||
mkdir -p "$SNAPSHOT"
|
||||
|
||||
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
|
||||
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
|
||||
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
|
||||
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
|
||||
|
||||
/usr/bin/shasum -a 256 \
|
||||
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
|
||||
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
|
||||
> "$SNAPSHOT/source-sha256.txt"
|
||||
|
||||
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
|
||||
| /usr/bin/sed "s#^$SOURCE/##" \
|
||||
| while IFS= read -r rel; do
|
||||
/usr/bin/shasum -a 256 "$SOURCE/$rel"
|
||||
done >> "$SNAPSHOT/source-sha256.txt"
|
||||
```
|
||||
|
||||
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
|
||||
|
||||
```bash
|
||||
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
|
||||
| while IFS= read -r file; do
|
||||
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
|
||||
done
|
||||
|
||||
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
|
||||
| while IFS= read -r file; do
|
||||
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
|
||||
done
|
||||
|
||||
EMPTY_IGNORE="$(mktemp)"
|
||||
trap 'rm -f "$EMPTY_IGNORE"' EXIT
|
||||
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
|
||||
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
|
||||
```
|
||||
|
||||
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
|
||||
|
||||
## Validation
|
||||
|
||||
After generation, verify the snapshot is ignored and key files are readable:
|
||||
|
||||
```bash
|
||||
git status --short --ignored | rg '\.cursor-app-formatted'
|
||||
wc -l \
|
||||
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
|
||||
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
|
||||
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
|
||||
```
|
||||
|
||||
Useful investigation check:
|
||||
|
||||
```bash
|
||||
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
|
||||
```
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
name: cursor-client-e2e-debugging
|
||||
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
|
||||
---
|
||||
|
||||
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
|
||||
|
||||
当用户只给一个 UUID / id,希望反查它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
|
||||
|
||||
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
|
||||
|
||||
## 首要约束
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
|
||||
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`。
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
|
||||
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
|
||||
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite`、`conversation.json`、`turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
|
||||
|
||||
## 先做路由判断
|
||||
|
||||
- `history + logs` 反查层
|
||||
- 现象:用户发来一个 id,要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`;需要从 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json` 追运行状态和语义历史。
|
||||
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
|
||||
- `provider replay / debug` 层
|
||||
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
|
||||
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
|
||||
- `cursor-agent` 层
|
||||
- 现象:`CursorAgentProvider`、`ClaudeSDKClient`、`AnthropicProxy`、`registerAgentProvider`、`InteractionUpdate` 映射、模型桥接异常。
|
||||
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
|
||||
- `cursor-always-local` / 本地模式协议层
|
||||
- 现象:`BidiAppend`、`RunSSE`、`AgentServerMessage`、`ExecClientMessage`、`InteractionResponse`、live checkpoint / pending 收口异常。
|
||||
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
|
||||
- 客户端 bundle 只读定位层
|
||||
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
|
||||
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
|
||||
|
||||
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
|
||||
|
||||
## 当前工作流
|
||||
|
||||
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items`、`state.json` 和 `logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`。
|
||||
2. 一旦拿到 `conversationId`,同时看两份事实源:
|
||||
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix`、`last_provider_call`。
|
||||
- `history/<conversationId>/context.json`:append-only 的语义历史 entries;prompt replay 由 `ProjectPromptReplay()` 从这里投影。
|
||||
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json`,`AppendLLMResponseChunk` 是 no-op,`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
|
||||
4. 再确认故障主要落在 `cursor-agent`、`cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
|
||||
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
|
||||
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body:
|
||||
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
|
||||
- 通过 id 反查拿到 `conversationId`、`requestId`、`modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`。
|
||||
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
|
||||
7. 如果用户要核对 prefix cache / cache hit:
|
||||
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
|
||||
- 它读取当前 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
|
||||
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
|
||||
8. 如果需要对照已安装 app 与仓库行为:
|
||||
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
|
||||
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
|
||||
- 先确认实际运行的 app 副本和目标 bundle 路径。
|
||||
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
|
||||
9. 如果证据显示问题更像是客户端 bundle 行为差异:
|
||||
- 记录具体文件、符号、日志和协议证据链。
|
||||
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
|
||||
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
|
||||
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId`、`requestId`、`modelCallId`、`toolCallId`。
|
||||
- 不要把 `history/<conversationId>/state.json` 和 `history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
|
||||
- 不要再依赖 `agent_request_runs`、`agent_conversations`、`agent_history_entries`、`protocol_traces`、`data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
|
||||
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
|
||||
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
|
||||
|
||||
## 模型渠道规则
|
||||
|
||||
- 模型渠道唯一性不再由 `modelID` 决定。
|
||||
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
|
||||
- resolver 仍兼容 legacy 渠道 ID:`baseURL + modelID + apiKey + displayName`。
|
||||
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`。
|
||||
|
||||
## 参考加载规则
|
||||
|
||||
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
|
||||
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
|
||||
- 文件地图:读 [references/file-map.md](references/file-map.md)
|
||||
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
|
||||
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
|
||||
|
||||
## 自带脚本
|
||||
|
||||
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
|
||||
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
|
||||
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG`、`REQUEST_ID`、`MODEL_CALL_ID`
|
||||
@@ -1,177 +0,0 @@
|
||||
# backend/store 日志反查
|
||||
|
||||
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
|
||||
|
||||
## 当前固定路径
|
||||
|
||||
当前用户机器上的固定助手根目录:
|
||||
|
||||
- `~/.cursor-local-assistant-v2`
|
||||
|
||||
当前最关键的是三类内容:
|
||||
|
||||
- `history/<conversationId>/state.json`
|
||||
- 会话元数据与当前状态。
|
||||
- 重点字段:`request_id` `conversation_id`、`root_conversation_id`、`parent_conversation_id`、`parent_tool_call_id`、`mode`、`current_loop_status`、`current_request_id`、`current_turn_seq`、`context_version`、`next_turn_seq`、`next_entry_seq`、`latest_request_prefix`、`last_provider_call`、`current_todos`、`current_plans`、token / compaction 字段。
|
||||
- `history/<conversationId>/context.json`
|
||||
- append-only 语义历史。
|
||||
- 重点字段:`version`、`items[]`。
|
||||
- `items[]` 中每个 entry 通常有 `seq`、`turn_seq`、`request_id`、`role`、`kind`、`tool_call_id`、`parent_tool_call_id`、`payload`、`created_at`。
|
||||
- `history/usage.json`
|
||||
- provider call 与 turn usage 聚合。
|
||||
- 重点字段:`totals`、`daily`、`recent_events`、`event_index`。
|
||||
|
||||
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
|
||||
|
||||
当前实现不再支持:
|
||||
|
||||
- DB-backed store / searchable conversation memory
|
||||
- HTTP/protocol trace debug UI
|
||||
- `data/data.sqlite` / `protocol_traces`
|
||||
- `history/<conversationId>/conversation.json`
|
||||
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
|
||||
- 根目录或会话目录下的旧 `latest.json`、`summary.json`、`replay.json`、`runtime.json`、`request.json`、`recovery.json`、`entries.jsonl`、数字 turn 目录
|
||||
|
||||
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
|
||||
|
||||
## 用户发来一个 id 时的固定步骤
|
||||
|
||||
### 1. 先判断 id 类型
|
||||
|
||||
不要先假设它是 `requestId`。按这个顺序缩小范围:
|
||||
|
||||
1. 是否是 `conversationId`
|
||||
- 检查 `history/<id>/state.json` 和 `history/<id>/context.json` 是否存在。
|
||||
2. 是否是 `requestId`
|
||||
- 在 `history/*/state.json` 中查 `current_request_id`、`latest_request_prefix.request_id`、`last_provider_call.request_id`。
|
||||
- 在 `history/*/context.json` 的 `items[].request_id` 中查。
|
||||
- 在 `logs/app.log` 中查。
|
||||
3. 是否是 `modelCallId`
|
||||
- 在 `state.json` 中查 `latest_request_prefix.model_call_id`、`last_provider_call.model_call_id`。
|
||||
- 在 `context.json.items[].payload` 中查 `model_call_id`。
|
||||
- 在 `logs/app.log` 中查 `model_call_id=<id>`。
|
||||
4. 是否是 `toolCallId` / `exec_id`
|
||||
- 在 `context.json.items[].tool_call_id`、`items[].payload` 中查。
|
||||
- 在协议/工具相关日志中查。
|
||||
|
||||
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
|
||||
|
||||
### 2. 拿到 `conversationId` 后看两份事实源
|
||||
|
||||
```bash
|
||||
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
|
||||
CONV_ID="<conversation-id>"
|
||||
|
||||
ls -la "$HISTORY_ROOT/$CONV_ID"
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- `state.json`
|
||||
- `current_loop_status`:`idle`、`running`、`waiting_tool`、`completed`、`canceled`、`provider_error`、`failed`
|
||||
- `current_request_id`、`current_turn_seq`
|
||||
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
|
||||
- `last_provider_call`:最近 provider 状态与错误文本
|
||||
- `next_entry_seq`、`next_turn_seq`、`context_version`
|
||||
- `current_todos`、`current_plans`
|
||||
- `context.json`
|
||||
- `version` 是否与 `state.context_version` 对齐
|
||||
- `items[]` 是否按 `seq` 稳定递增
|
||||
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
|
||||
- 是否有重复、缺失或顺序异常
|
||||
- `usage.json`
|
||||
- 通过 `event_index` 或 `recent_events` 查 request/model-call 相关 usage
|
||||
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
|
||||
|
||||
### 3. Provider 调用证据现在在哪里
|
||||
|
||||
当前 provider artifact recorder 的行为:
|
||||
|
||||
- `RecordLLMRequest(...)`
|
||||
- 只缓存当前 provider call 的请求摘要。
|
||||
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`。
|
||||
- 不再写 `request.json`。
|
||||
- `AppendLLMResponseChunk(...)`
|
||||
- 当前是 no-op。
|
||||
- 不再写 `sse.jsonl`。
|
||||
- `RecordLLMSummary(...)`
|
||||
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`。
|
||||
- usage 聚合写入 `history/usage.json`。
|
||||
- 不再写 `summary.json`。
|
||||
|
||||
所以 provider 错误排查应优先看:
|
||||
|
||||
- `state.last_provider_call`
|
||||
- `state.latest_request_prefix`
|
||||
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
|
||||
- `history/usage.json`
|
||||
- `logs/app.log`
|
||||
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
|
||||
|
||||
## 这些文件是怎么生成的
|
||||
|
||||
### 根路径
|
||||
|
||||
- `internal/appdata/paths.go`
|
||||
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
|
||||
- `HistoryRootPath()` 为 `~/.cursor-local-assistant-v2/history`
|
||||
- `UsageFilePath()` 为 `~/.cursor-local-assistant-v2/history/usage.json`
|
||||
- `LogsRootPath()` 为 `~/.cursor-local-assistant-v2/logs`
|
||||
|
||||
### `state.json + context.json`
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/file_store.go`
|
||||
- `CreateConversation`
|
||||
- `LoadConversation`
|
||||
- `AppendEntries`
|
||||
- `SaveConversationWithEntries`
|
||||
- `UpdateConversationMeta`
|
||||
- `ReplaceEntries`
|
||||
- `internal/backend/forwarder/service.go`
|
||||
- `handleRunIntent` 开始新 loop / turn
|
||||
- `appendConversationEntries` 追加语义事件
|
||||
- `internal/backend/forwarder/projector.go`
|
||||
- `ProjectPromptReplay()` 把 `context.json.items` 投影为 provider messages
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `state.json` 是当前状态和可变元数据。
|
||||
- `context.json.items` 是 replayable 语义历史。
|
||||
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
|
||||
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`。
|
||||
|
||||
### `usage.json`
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/usage_store.go`
|
||||
- `UsageFileStore.UpsertEvent`
|
||||
- `UsageFileStore.LookupEvent`
|
||||
- `internal/backend/forwarder/token_usage.go`
|
||||
- `internal/historymetrics/`
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
|
||||
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`。
|
||||
|
||||
### legacy 清理
|
||||
|
||||
来源链路:
|
||||
|
||||
- `internal/backend/forwarder/history_maintenance.go`
|
||||
|
||||
稳定结论:
|
||||
|
||||
- `turns/`、`conversation.json`、`entries.jsonl`、`request.json`、`summary.json` 等都是 legacy artifact。
|
||||
- 启动后的 history maintenance 会清理这些旧产物。
|
||||
|
||||
## 快速判断规则
|
||||
|
||||
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`。
|
||||
- 请求失败时,先看 `state.last_provider_call`、`context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`。
|
||||
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`。
|
||||
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
|
||||
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
|
||||
@@ -1,147 +0,0 @@
|
||||
# 文件地图
|
||||
|
||||
## 已安装客户端 bundle
|
||||
|
||||
优先核对这些实际运行中的客户端文件:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
|
||||
|
||||
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec`、`cursor-agent-worker`、`cursor-always-local` 及其 `dist/` 文件。
|
||||
|
||||
大致归属:
|
||||
|
||||
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
|
||||
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend`、`RunSSE`、`AgentServerMessage` / `AgentClientMessage` 桥接。
|
||||
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunk:agent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
|
||||
- `cursor-agent-worker/dist/main.js`:agent worker 侧后台逻辑。
|
||||
|
||||
用户机器上可能还存在其它 app 副本,例如:
|
||||
|
||||
- `~/Applications/Cursor Hooked.app`
|
||||
- `/Applications/Cursor Patched.app`
|
||||
|
||||
不要假设哪一个在跑,先看进程路径。
|
||||
|
||||
## 当前 backend/store 与 history
|
||||
|
||||
当前用户机器上的固定助手目录:
|
||||
|
||||
- `~/.cursor-local-assistant-v2/`
|
||||
|
||||
重点看:
|
||||
|
||||
- `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.crt`
|
||||
- `~/.cursor-local-assistant-v2/data/ads/`
|
||||
- `~/.cursor-local-assistant-v2/history/usage.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
|
||||
- `~/.cursor-local-assistant-v2/logs/app.log`
|
||||
|
||||
其中:
|
||||
|
||||
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
|
||||
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
|
||||
- `usage.json` 是全局 provider call / turn usage 聚合。
|
||||
- `conversation.lock` 是会话级文件锁。
|
||||
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
|
||||
- legacy artifacts:`conversation.json`、`entries.jsonl`、`turns/`、`request.json`、`summary.json`、`sse.jsonl`、`replay.json`、`runtime.json`、`latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
|
||||
|
||||
这些内容的生成入口主要在:
|
||||
|
||||
- `internal/appdata/paths.go`
|
||||
- `internal/backend/host.go`
|
||||
- `internal/backend/README.md`
|
||||
- `internal/backend/forwarder/file_store.go`
|
||||
- `internal/backend/forwarder/history_maintenance.go`
|
||||
- `internal/backend/forwarder/usage_store.go`
|
||||
- `internal/backend/forwarder/token_usage.go`
|
||||
- `internal/backend/forwarder/artifacts.go`
|
||||
|
||||
## 本仓库协议与本地模式实现
|
||||
|
||||
协议定义:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `proto/aiserver_v1.proto`
|
||||
- `proto/from_extensions/agent_v1.proto`
|
||||
- `proto/from_extensions/aiserver_v1.proto`
|
||||
|
||||
扩展快照与提取:
|
||||
|
||||
- `proto/extensions-cursor-app/cursor-always-local/package.json`
|
||||
- `proto/extract_extensions_proto.sh`
|
||||
- `proto/ext_tool/main.go`
|
||||
|
||||
本地后端入口:
|
||||
|
||||
- `internal/backend/host.go`
|
||||
- `internal/backend/server/route.go`
|
||||
- `internal/backend/server/policy.go`
|
||||
- `internal/backend/server/local.go`
|
||||
- `internal/backend/server/config/types.go`
|
||||
- `internal/backend/server/config/manager.go`
|
||||
- `internal/backend/server/config/resolver.go`
|
||||
|
||||
forwarder 主链路:
|
||||
|
||||
- `internal/backend/forwarder/module.go`
|
||||
- `internal/backend/forwarder/service.go`
|
||||
- `internal/backend/forwarder/actor.go`
|
||||
- `internal/backend/forwarder/broker.go`
|
||||
- `internal/backend/forwarder/events.go`
|
||||
- `internal/backend/forwarder/compiler.go`
|
||||
- `internal/backend/forwarder/projector.go`
|
||||
- `internal/backend/forwarder/provider.go`
|
||||
- `internal/backend/forwarder/checkpoint_memory.go`
|
||||
- `internal/backend/forwarder/runtime_summary.go`
|
||||
|
||||
协议解码:
|
||||
|
||||
- `internal/backend/agent/protocol/inbound.go`
|
||||
|
||||
执行桥 / 交互桥:
|
||||
|
||||
- `internal/backend/agent/bridge/exec/bridge.go`
|
||||
- `internal/backend/agent/bridge/interaction/bridge.go`
|
||||
|
||||
模型适配:
|
||||
|
||||
- `internal/backend/agent/model/router.go`
|
||||
- `internal/backend/agent/model/openai.go`
|
||||
- `internal/backend/agent/model/anthropic.go`
|
||||
- `internal/backend/agent/model/artifacts.go`
|
||||
- `internal/backend/agent/model/http_error.go`
|
||||
- `internal/backend/agent/model/tool_call_id.go`
|
||||
- `internal/modelchannel/identity.go`
|
||||
- `internal/runtime/local_runtime.go`
|
||||
|
||||
Prompt / replay:
|
||||
|
||||
- `internal/backend/agent/prompt/engine.go`
|
||||
- `internal/backend/agent/prompt/replay.go`
|
||||
- `internal/backend/agent/prompt/content_parts.go`
|
||||
- `internal/backend/forwarder/prompt_context.go`
|
||||
- `internal/backend/forwarder/request_context.go`
|
||||
- `internal/backend/forwarder/reminders.go`
|
||||
- `internal/backend/forwarder/prompt_guard.go`
|
||||
|
||||
## 构建相关参考(只读)
|
||||
|
||||
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
|
||||
|
||||
- `Taskfile.yml`
|
||||
- `build/darwin/Taskfile.yml`
|
||||
- `build/dmg-extras/提示损坏?点我.command`
|
||||
|
||||
重点看:
|
||||
|
||||
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
|
||||
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
# 已安装客户端只读核对与验证
|
||||
|
||||
首要原则:
|
||||
|
||||
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
|
||||
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
|
||||
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
|
||||
|
||||
## 1. 先确认实际运行的 app 副本
|
||||
|
||||
优先用非交互命令核对:
|
||||
|
||||
```bash
|
||||
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
|
||||
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
|
||||
```
|
||||
|
||||
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
|
||||
|
||||
## 2. 只读定位目标 bundle 与关键文件
|
||||
|
||||
优先定位并读取这些文件,而不是改写它们:
|
||||
|
||||
```bash
|
||||
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
|
||||
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
- `out/vs/workbench/workbench.desktop.main.js`
|
||||
- `out/vs/workbench/api/node/extensionHostProcess.js`
|
||||
- `cursor-always-local/dist/main.js`
|
||||
- `cursor-always-local/dist/gitWorker.js`
|
||||
- `cursor-agent-exec/dist/main.js`
|
||||
- `cursor-agent-exec/dist/*.js`
|
||||
- `cursor-agent-worker/dist/main.js`
|
||||
|
||||
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
|
||||
|
||||
## 3. 只读读取 bundle 内容
|
||||
|
||||
常用定位关键词:
|
||||
|
||||
```bash
|
||||
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
|
||||
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
|
||||
```
|
||||
|
||||
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
|
||||
|
||||
如果需要和仓库实现对照,优先同时打开:
|
||||
|
||||
- `proto/agent_v1.proto`
|
||||
- `proto/aiserver_v1.proto`
|
||||
- `internal/backend/...`
|
||||
- `internal/runtime/local_runtime.go`
|
||||
|
||||
## 4. 验证行为是否命中目标副本
|
||||
|
||||
至少做其中两项:
|
||||
|
||||
- 进程路径是否是目标 app
|
||||
- 目标扩展 host 是否起来
|
||||
- 本地监听端口是否存在
|
||||
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
|
||||
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
|
||||
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
|
||||
|
||||
常用验证:
|
||||
|
||||
```bash
|
||||
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
|
||||
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
|
||||
```
|
||||
|
||||
## 5. 记录证据并输出归因
|
||||
|
||||
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
|
||||
|
||||
1. 实际运行的 app 路径
|
||||
2. 命中的 bundle 文件路径与关键符号
|
||||
3. 对应日志、端口、`history/state.json`、`history/context.json`、`usage.json` 证据
|
||||
4. 仓库里对应实现的位置
|
||||
|
||||
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
|
||||
@@ -1,153 +0,0 @@
|
||||
# Provider replay / debug
|
||||
|
||||
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
|
||||
|
||||
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
|
||||
|
||||
## 证据边界
|
||||
|
||||
- `history/<conversationId>/debug/provider.jsonl`
|
||||
- 最接近 provider 出站边界。
|
||||
- `event=llm_request` 的 `payload.body` 是最终 provider request body。
|
||||
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
|
||||
- `history/<conversationId>/state.json`
|
||||
- 当前状态和最近 provider 摘要。
|
||||
- 重点看 `latest_request_prefix`、`last_provider_call`。
|
||||
- `history/<conversationId>/context.json`
|
||||
- replayable 语义历史。
|
||||
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
|
||||
- `history/<conversationId>/debug/bidi.raw.jsonl`
|
||||
- 客户端原始上行字节证据。
|
||||
- `history/<conversationId>/debug/bidi.decoded.jsonl`
|
||||
- 当前 known-schema 解码后的客户端上行证据。
|
||||
- `history/<conversationId>/debug/runtime.jsonl`
|
||||
- 后端把哪些字段挂到 active request / stream 上。
|
||||
- `history/<conversationId>/debug/runsse.jsonl`
|
||||
- 后端尝试发回客户端的消息。
|
||||
|
||||
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 请求发生时 `config.yaml` 中 `log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`。
|
||||
- 已经通过 id 反查拿到:
|
||||
- `conversationId`
|
||||
- `requestId`
|
||||
- `modelCallId`
|
||||
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
|
||||
|
||||
如果没有 debug 文件,先回到 `state.json`、`context.json`、`usage.json`、`logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
|
||||
|
||||
## 最小流程
|
||||
|
||||
1. 定位 provider debug 文件:
|
||||
|
||||
```bash
|
||||
ROOT="$HOME/.cursor-local-assistant-v2"
|
||||
CONV="<conversationId>"
|
||||
REQ="<requestId>"
|
||||
MODEL_CALL="<modelCallId>"
|
||||
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
|
||||
```
|
||||
|
||||
2. 确认 `llm_request` 存在:
|
||||
|
||||
```bash
|
||||
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
|
||||
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
|
||||
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
|
||||
' "$REQUEST_LOG"
|
||||
```
|
||||
|
||||
3. 执行通用重放脚本:
|
||||
|
||||
```bash
|
||||
REQUEST_LOG="$REQUEST_LOG" \
|
||||
REQUEST_ID="$REQ" \
|
||||
MODEL_CALL_ID="$MODEL_CALL" \
|
||||
CHANNEL_NAME="GLM" \
|
||||
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
|
||||
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
|
||||
```
|
||||
|
||||
也可以直接传入 provider 配置,避免读取 `config.yaml`:
|
||||
|
||||
```bash
|
||||
REQUEST_LOG="$REQUEST_LOG" \
|
||||
REQUEST_ID="$REQ" \
|
||||
MODEL_CALL_ID="$MODEL_CALL" \
|
||||
BASE_URL="<provider-base-url>" \
|
||||
API_KEY="<provider-api-key>" \
|
||||
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
|
||||
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
|
||||
```
|
||||
|
||||
4. 查看产物:
|
||||
|
||||
- `request.body.json`:抽取出的最终 provider body。
|
||||
- `response.headers`:HTTP 响应头。
|
||||
- `response.sse`:SSE 响应体。
|
||||
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
|
||||
|
||||
## 结果判断
|
||||
|
||||
- `curl_exit_code != 0`
|
||||
- 网络、TLS、超时、连接或本机 curl 问题。
|
||||
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
|
||||
- HTTP 非 2xx
|
||||
- provider 网关或鉴权层拒绝。
|
||||
- 优先看 `response.headers` 和 provider 错误体。
|
||||
- HTTP 2xx 但 SSE 中有 `event: error`
|
||||
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
|
||||
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
|
||||
- SSE 正常流式输出
|
||||
- 原始 provider 请求形状基本可用。
|
||||
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
|
||||
|
||||
## 常见收敛方向
|
||||
|
||||
provider 参数错误时,优先检查:
|
||||
|
||||
- `model` 是否是目标 endpoint 支持的名称。
|
||||
- `messages` 是否符合 Anthropic-compatible 形态。
|
||||
- `system` 是否被目标 provider 支持,或需要改成 message。
|
||||
- `tools` / `tool_choice` 是否符合目标 provider 方言。
|
||||
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
|
||||
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
|
||||
- `max_tokens`、`temperature`、`top_p`、`stop_sequences` 是否落在 provider 允许范围内。
|
||||
|
||||
## 敏感信息规则
|
||||
|
||||
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
|
||||
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
|
||||
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
|
||||
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
|
||||
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
|
||||
|
||||
## 脚本参数
|
||||
|
||||
`scripts/provider-replay.sh` 使用环境变量控制:
|
||||
|
||||
- 必填:
|
||||
- `REQUEST_LOG`
|
||||
- `REQUEST_ID`
|
||||
- `MODEL_CALL_ID`
|
||||
- 可选:
|
||||
- `BASE_URL`
|
||||
- `API_KEY`
|
||||
- `GLM_BASE_URL`
|
||||
- `GLM_API_KEY`
|
||||
- `ANTHROPIC_BASE_URL`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `CHANNEL_NAME`,默认 `GLM`
|
||||
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
|
||||
- `MAX_TIME`,默认 `240`
|
||||
- `ENDPOINT_PATH`,默认 `/v1/messages`
|
||||
|
||||
脚本输出四个稳定产物:
|
||||
|
||||
- `request.body.json`
|
||||
- `response.headers`
|
||||
- `response.sse`
|
||||
- `replay.meta.json`
|
||||
@@ -1,223 +0,0 @@
|
||||
# 搜索词与判断树
|
||||
|
||||
## 先判断层级
|
||||
|
||||
### `history + logs` / id 反查层
|
||||
|
||||
当现象是“用户只给了一个 id”“需要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`”“要从本地 history 和日志追状态”:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `state.json`
|
||||
- `context.json`
|
||||
- `usage.json`
|
||||
- `logs/app.log`
|
||||
- `conversation_id`
|
||||
- `current_request_id`
|
||||
- `request_id`
|
||||
- `model_call_id`
|
||||
- `tool_call_id`
|
||||
- `latest_request_prefix`
|
||||
- `last_provider_call`
|
||||
- `current_loop_status`
|
||||
- `context_version`
|
||||
- `next_entry_seq`
|
||||
- `next_turn_seq`
|
||||
- `LoadConversation`
|
||||
- `CreateConversation`
|
||||
- `SaveConversationWithEntries`
|
||||
- `AppendEntries`
|
||||
- `UpdateConversationMeta`
|
||||
- `ReplaceEntries`
|
||||
- `ProjectPromptReplay`
|
||||
- `UsageFileStore`
|
||||
- `UpsertEvent`
|
||||
- `LookupEvent`
|
||||
|
||||
不要再优先搜索或依赖:
|
||||
|
||||
- `data.sqlite`
|
||||
- `protocol_traces`
|
||||
- `agent_request_runs`
|
||||
- `conversation.json`
|
||||
- `entries.jsonl`
|
||||
- `turns/<n>`
|
||||
- `request.json`
|
||||
- `sse.jsonl`
|
||||
- `summary.json`
|
||||
|
||||
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
|
||||
|
||||
### `cursor-agent-exec` / `cursor-agent-worker` 层
|
||||
|
||||
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
|
||||
|
||||
优先在已安装客户端 split bundle 中搜索:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `registerAgentProvider`
|
||||
- `CursorAgentProvider`
|
||||
- `CursorAgentProviderHandle`
|
||||
- `ClaudeSDKClient`
|
||||
- `streamInteractionUpdates`
|
||||
- `handlePartialMessage`
|
||||
- `AnthropicProxy`
|
||||
- `getAnthropicProxyPort`
|
||||
- `getAnthropicProxyAuthToken`
|
||||
- `ANTHROPIC_BASE_URL`
|
||||
- `ANTHROPIC_API_KEY`
|
||||
- `InteractionUpdate`
|
||||
- `checkpoint`
|
||||
|
||||
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
|
||||
|
||||
### agent window / conversation metadata UI 层
|
||||
|
||||
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
|
||||
|
||||
优先在已安装客户端主 UI 和 split bundle 中搜索:
|
||||
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
|
||||
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `shouldShowAgentWindowTitleHelperText`
|
||||
- `glass_open_agents_titlebar_button`
|
||||
- `open_agent_window_top`
|
||||
- `open_agent_window_bottom_convo`
|
||||
- `glass.enable_open_agent_in_window`
|
||||
- `NameAgentRequest`
|
||||
- `NameAgentResponse`
|
||||
- `UpdateConversationMetadataRequest`
|
||||
- `UpdateConversationMetadataResponse`
|
||||
- `CreateTranscriptOverviewRequest`
|
||||
- `createTranscriptOverview`
|
||||
- `updateConversationMetadata`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
判断规则:
|
||||
|
||||
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
|
||||
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
|
||||
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
|
||||
|
||||
### `cursor-always-local` / 协议层
|
||||
|
||||
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `BidiTransport`
|
||||
- `startYieldingInputsToTheServer`
|
||||
- `BidiAppend`
|
||||
- `RunSSE`
|
||||
- `AgentServerMessage`
|
||||
- `AgentClientMessage`
|
||||
- `ExecServerMessage`
|
||||
- `ExecClientMessage`
|
||||
- `ExecClientControlMessage`
|
||||
- `InteractionQuery`
|
||||
- `InteractionResponse`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
### 本仓库 forwarder 层
|
||||
|
||||
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `handleRunIntent`
|
||||
- `driveProvider`
|
||||
- `startStreamActor`
|
||||
- `streamCommandEnvelope`
|
||||
- `handleToolInvocation`
|
||||
- `handleExecResult`
|
||||
- `handleExecControl`
|
||||
- `publishCheckpoint`
|
||||
- `CheckpointConversation`
|
||||
- `snapshotCheckpointConversation`
|
||||
- `appendConversationEntries`
|
||||
- `OpenExec`
|
||||
- `OpenQuery`
|
||||
- `StartStream`
|
||||
- `deriveConversationLoopState`
|
||||
- `historyEntryToolCallID`
|
||||
- `recordProviderUsage`
|
||||
- `recordTurnUsage`
|
||||
|
||||
### provider / 模型适配层
|
||||
|
||||
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
|
||||
|
||||
优先搜索:
|
||||
|
||||
- `StartStream`
|
||||
- `StreamRequest`
|
||||
- `ResolvedChannelID`
|
||||
- `ResolvedChannelName`
|
||||
- `ProviderModelID`
|
||||
- `ThinkingEnabled`
|
||||
- `buildAnthropicThinkingConfig`
|
||||
- `normalizeAnthropicProviderMessages`
|
||||
- `normalizeOpenAIProviderMessages`
|
||||
- `normalizeOpenAIResponsesInput`
|
||||
- `reasoning_content`
|
||||
- `ReasoningContent`
|
||||
- `ReasoningSignature`
|
||||
- `RecordLLMRequest`
|
||||
- `RecordLLMSummary`
|
||||
- `http_error`
|
||||
- `namespaceToolCallID`
|
||||
|
||||
## 快速判断规则
|
||||
|
||||
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json`、`history/*/context.json` 和 `logs/app.log`。
|
||||
- 如果问题是“模型输出语义不对”,先看 `context.json.items` 到 `ProjectPromptReplay()` 的投影,再看 provider request normalization。
|
||||
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix`、`state.last_provider_call`、`logs/app.log`。
|
||||
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
|
||||
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpoint;checkpoint 是 live state,重启后的事实源是 `state.json + context.json`。
|
||||
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256;resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
|
||||
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
|
||||
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
|
||||
|
||||
## 协议关键词
|
||||
|
||||
上行:
|
||||
|
||||
- `run_request`
|
||||
- `exec_client_message`
|
||||
- `exec_client_control_message`
|
||||
- `interaction_response`
|
||||
|
||||
下行:
|
||||
|
||||
- `interaction_update`
|
||||
- `exec_server_message`
|
||||
- `exec_server_control_message`
|
||||
- `interaction_query`
|
||||
- `conversation_checkpoint_update`
|
||||
|
||||
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
|
||||
|
||||
- `exec_id`
|
||||
- `id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `model_call_id`
|
||||
- pending 收口逻辑
|
||||
|
||||
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
|
||||
|
||||
- `history/<id>/state.json`
|
||||
- `history/*/state.json` 的 `current_request_id`、`latest_request_prefix`、`last_provider_call`
|
||||
- `history/*/context.json` 的 `items[].request_id`、`items[].tool_call_id`、`items[].payload`
|
||||
- `history/usage.json` 的 `event_index` / `recent_events`
|
||||
- `logs/app.log`
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
|
||||
const repoRoot = path.resolve(skillRoot, "../../..");
|
||||
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
|
||||
|
||||
const child = spawn("go", args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`cache-hit-rate.mjs failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (typeof code === "number") {
|
||||
process.exitCode = code;
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
|
||||
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
|
||||
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
|
||||
|
||||
Required:
|
||||
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
|
||||
REQUEST_ID Provider request_id to replay
|
||||
MODEL_CALL_ID Provider model_call_id to replay
|
||||
|
||||
Optional:
|
||||
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
|
||||
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
|
||||
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
|
||||
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
|
||||
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
|
||||
MAX_TIME curl max-time seconds. Defaults to 240.
|
||||
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REQUEST_LOG="${REQUEST_LOG:-}"
|
||||
REQUEST_ID="${REQUEST_ID:-}"
|
||||
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
|
||||
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
|
||||
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
|
||||
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
|
||||
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
|
||||
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
|
||||
MAX_TIME="${MAX_TIME:-240}"
|
||||
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
|
||||
BODY_FILE="$OUT_DIR/request.body.json"
|
||||
RESP_FILE="$OUT_DIR/response.sse"
|
||||
HEADER_FILE="$OUT_DIR/response.headers"
|
||||
META_FILE="$OUT_DIR/replay.meta.json"
|
||||
|
||||
require_value() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [[ -z "$value" ]]; then
|
||||
echo "缺少 $name。运行 --help 查看用法。" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
read_channel_config() {
|
||||
local field="$1"
|
||||
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
config_path = Path(sys.argv[1]).expanduser()
|
||||
channel_name = sys.argv[2]
|
||||
field = sys.argv[3]
|
||||
if not config_path.exists():
|
||||
raise SystemExit
|
||||
|
||||
lines = config_path.read_text(encoding="utf-8").splitlines()
|
||||
in_channel = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("- displayName:"):
|
||||
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
|
||||
continue
|
||||
if in_channel and stripped.startswith(field + ":"):
|
||||
print(stripped.split(":", 1)[1].strip().strip('"'))
|
||||
raise SystemExit
|
||||
PY
|
||||
}
|
||||
|
||||
require_value "REQUEST_LOG" "$REQUEST_LOG"
|
||||
require_value "REQUEST_ID" "$REQUEST_ID"
|
||||
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
|
||||
|
||||
if [[ ! -f "$REQUEST_LOG" ]]; then
|
||||
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "$BASE_URL" ]]; then
|
||||
BASE_URL="$(read_channel_config baseURL || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$API_KEY" ]]; then
|
||||
API_KEY="$(read_channel_config apiKey || true)"
|
||||
fi
|
||||
|
||||
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
|
||||
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
: > "$HEADER_FILE"
|
||||
: > "$RESP_FILE"
|
||||
|
||||
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
log_path = Path(sys.argv[1]).expanduser()
|
||||
request_id = sys.argv[2]
|
||||
model_call_id = sys.argv[3]
|
||||
body_path = Path(sys.argv[4])
|
||||
meta_path = Path(sys.argv[5])
|
||||
|
||||
body = None
|
||||
meta = None
|
||||
with log_path.open(encoding="utf-8") as f:
|
||||
for raw in f:
|
||||
if not raw.strip():
|
||||
continue
|
||||
row = json.loads(raw)
|
||||
if row.get("event") != "llm_request":
|
||||
continue
|
||||
if row.get("request_id") != request_id:
|
||||
continue
|
||||
if row.get("model_call_id") != model_call_id:
|
||||
continue
|
||||
payload = row.get("payload") or {}
|
||||
body = payload.get("body")
|
||||
meta = {
|
||||
"at": row.get("at"),
|
||||
"event": row.get("event"),
|
||||
"conversation_id": row.get("conversation_id"),
|
||||
"request_id": row.get("request_id"),
|
||||
"model_call_id": row.get("model_call_id"),
|
||||
"provider_log": str(log_path),
|
||||
}
|
||||
break
|
||||
|
||||
if body is None:
|
||||
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
|
||||
|
||||
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
url="${BASE_URL%/}${ENDPOINT_PATH}"
|
||||
|
||||
set +e
|
||||
curl --no-buffer --silent --show-error \
|
||||
--connect-timeout 30 \
|
||||
--max-time "$MAX_TIME" \
|
||||
--request POST "$url" \
|
||||
--header "content-type: application/json" \
|
||||
--header "anthropic-version: 2023-06-01" \
|
||||
--header "User-Agent: claude-cli/1.0.25" \
|
||||
--header "x-api-key: $API_KEY" \
|
||||
--header "Authorization: Bearer $API_KEY" \
|
||||
--data-binary "@$BODY_FILE" \
|
||||
--dump-header "$HEADER_FILE" \
|
||||
--output "$RESP_FILE"
|
||||
code=$?
|
||||
set -e
|
||||
|
||||
echo "curl_exit_code=$code"
|
||||
echo "body=$BODY_FILE"
|
||||
echo "headers=$HEADER_FILE"
|
||||
echo "response=$RESP_FILE"
|
||||
echo "meta=$META_FILE"
|
||||
|
||||
echo "--- response headers ---"
|
||||
if [[ -f "$HEADER_FILE" ]]; then
|
||||
python3 - "$HEADER_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
|
||||
print(line)
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "--- response first 120 lines ---"
|
||||
if [[ -f "$RESP_FILE" ]]; then
|
||||
python3 - "$RESP_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
|
||||
print(line)
|
||||
PY
|
||||
else
|
||||
echo "响应文件不存在。"
|
||||
fi
|
||||
|
||||
exit "$code"
|
||||
@@ -1,176 +0,0 @@
|
||||
---
|
||||
name: cursor-debug-log
|
||||
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
|
||||
---
|
||||
|
||||
# Cursor Debug Log
|
||||
|
||||
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
|
||||
|
||||
## 作用定位
|
||||
|
||||
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
|
||||
|
||||
- 用来回答“客户端到底发了什么”。
|
||||
- 用来回答“后端解码后认为这是什么请求”。
|
||||
- 用来回答“哪些字段被挂到了当前 active request 上”。
|
||||
- 用来回答“最终 provider request body 是什么样”。
|
||||
- 用来回答“RunSSE 实际给客户端发送了什么”。
|
||||
- 不要把它当成 replay history、prompt 输入或状态事实源。
|
||||
|
||||
稳定事实源仍然是:
|
||||
|
||||
- `history/<conversationId>/state.json`
|
||||
- `history/<conversationId>/context.json`
|
||||
- `history/usage.json`
|
||||
- `logs/app.log`
|
||||
|
||||
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
|
||||
|
||||
## 固定路径
|
||||
|
||||
- 助手根目录:`~/.cursor-local-assistant-v2`
|
||||
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
|
||||
- history 根目录:`~/.cursor-local-assistant-v2/history`
|
||||
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
|
||||
- 会话 debug 目录:`history/<conversationId>/debug/`
|
||||
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
|
||||
|
||||
通过配置开启 debug logging:
|
||||
|
||||
```yaml
|
||||
log: true
|
||||
```
|
||||
|
||||
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
|
||||
|
||||
## 文件如何生成
|
||||
|
||||
debug 层随着请求穿过后端边界逐步落盘:
|
||||
|
||||
1. `BidiAppend` 收到客户端上行数据。
|
||||
- 原始 hex 写入 `bidi.raw.jsonl`。
|
||||
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`。
|
||||
2. forwarder 把解码结果转成 active runtime state。
|
||||
- stream/request 状态决策写入 `runtime.jsonl`。
|
||||
3. provider pass 被准备并执行。
|
||||
- adapter 前的请求摘要、`model_call_id`、`provider_pass` 等写入 `provider.jsonl`。
|
||||
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`。
|
||||
4. `RunSSE` 把后端输出流式发送给客户端。
|
||||
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`。
|
||||
|
||||
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
|
||||
|
||||
## Debug 文件含义
|
||||
|
||||
`bidi.raw.jsonl`
|
||||
|
||||
- 方向:客户端到后端。
|
||||
- 包含 `request_id`、可选 `conversation_id`、`append_seqno`、`status`、原始 `data_hex`。
|
||||
- 当需要精确确认客户端上传字节时先看它。
|
||||
|
||||
`bidi.decoded.jsonl`
|
||||
|
||||
- 方向:客户端到后端,protobuf 解码后。
|
||||
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson:`message`。
|
||||
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message`、`user_message`、`request_context`、`conversation_state`、exec/interaction/kv 回包等。
|
||||
- 还包含 `message_case`、`requested_model`、`conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
|
||||
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
|
||||
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message` 和 `intent` 里的完整字段。
|
||||
|
||||
`runtime.jsonl`
|
||||
|
||||
- 方向:后端内部 runtime。
|
||||
- 包含状态流转,以及挂到 active stream/request 上的字段。
|
||||
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
|
||||
|
||||
`provider.jsonl`
|
||||
|
||||
- 方向:后端到 provider adapter/provider。
|
||||
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
|
||||
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
|
||||
|
||||
`runsse.jsonl`
|
||||
|
||||
- 方向:后端到客户端。
|
||||
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
|
||||
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
|
||||
|
||||
## 查询流程
|
||||
|
||||
1. 先判断 id 类型。
|
||||
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`。
|
||||
- 再在 `history/*/{state.json,context.json}`、`history/usage.json`、`logs/app.log` 里搜索 request/model-call/tool id。
|
||||
2. 拿到 `conversationId` 后,列出 debug 目录。
|
||||
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
|
||||
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
|
||||
- 读取 `config.yaml`。
|
||||
- 对比 `config.yaml`、`state.json`、`context.json` 的 mtime。
|
||||
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
|
||||
4. 按时间顺序读 JSONL,并用这些字段串联:
|
||||
- `request_id`
|
||||
- `conversation_id`
|
||||
- `model_call_id`
|
||||
- `provider_pass`
|
||||
- `append_seqno`
|
||||
- event timestamp
|
||||
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
ROOT="$HOME/.cursor-local-assistant-v2"
|
||||
REQ="<requestId>"
|
||||
CONV="<conversationId>"
|
||||
|
||||
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
|
||||
find "$ROOT/history" -path "*/debug/*" -type f | sort
|
||||
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
|
||||
```
|
||||
|
||||
紧凑查看 JSONL:
|
||||
|
||||
```bash
|
||||
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
|
||||
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
|
||||
```
|
||||
|
||||
## 证据怎么用
|
||||
|
||||
根据问题选择对应文件:
|
||||
|
||||
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
|
||||
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl` 的 `message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
|
||||
- 后端如何理解请求:看 `bidi.decoded.jsonl` 的 `intent`,再接 `runtime.jsonl`。
|
||||
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`。
|
||||
- UI/流式输出问题:看 `runsse.jsonl`。
|
||||
- 请求状态问题:先看 `state.json`、`context.json`、`usage.json`,再用 debug 文件补证。
|
||||
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
|
||||
|
||||
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
|
||||
|
||||
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
|
||||
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
|
||||
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
|
||||
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
|
||||
- `provider.jsonl` 说明最终为 provider 准备了什么。
|
||||
|
||||
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
|
||||
|
||||
注意证据边界:
|
||||
|
||||
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`。
|
||||
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
|
||||
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
|
||||
|
||||
## Debug 文件缺失
|
||||
|
||||
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
|
||||
|
||||
- 请求发生时 `log: false`。
|
||||
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
|
||||
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`。
|
||||
- 请求在开启 `log` 前已经完成。
|
||||
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
|
||||
|
||||
debug 证据缺失时,回退到 `state.json`、`context.json`、`usage.json`、`logs/app.log`,并把结论标成推断,而不是直接证明。
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: i18n-requirements
|
||||
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
|
||||
---
|
||||
|
||||
# I18n Requirements
|
||||
|
||||
## Source Messages
|
||||
|
||||
- Treat `zh-CN` as the only source locale.
|
||||
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
|
||||
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
|
||||
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
|
||||
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
|
||||
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
|
||||
|
||||
## Generated Catalogs
|
||||
|
||||
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
|
||||
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
|
||||
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
|
||||
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
|
||||
|
||||
## Adding A Locale
|
||||
|
||||
Update all of these integration points together:
|
||||
|
||||
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
|
||||
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
|
||||
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
|
||||
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
|
||||
- Native tray labels in `internal/app/runner.go`.
|
||||
|
||||
## Verification
|
||||
|
||||
After the scan build:
|
||||
|
||||
1. Confirm `npm run build` succeeds.
|
||||
2. Confirm every locale JSON has the same keys as `catalog.json`.
|
||||
3. Confirm non-source locale files contain no empty values.
|
||||
4. Confirm translated placeholders match the source entry placeholders.
|
||||
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "I18n Requirements"
|
||||
short_description: "Keep UI translations and generated catalogs in sync"
|
||||
default_prompt: "Use $i18n-requirements to update localized UI text safely."
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: prefix-cache-stability
|
||||
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
|
||||
---
|
||||
|
||||
# Prefix Cache Stability
|
||||
|
||||
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
|
||||
- Do not move previously sent model-visible messages to a new position in later requests.
|
||||
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
|
||||
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
|
||||
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
|
||||
- Never optimize cache by dropping correctness-critical context.
|
||||
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
1. Classify each prompt addition:
|
||||
- Stable system policy: belongs in the fixed system prompt.
|
||||
- Historical model-visible context: persist as replayable history.
|
||||
- Latest-only attention: append as current suffix, do not persist.
|
||||
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
|
||||
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
|
||||
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
|
||||
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
|
||||
|
||||
## Verification
|
||||
|
||||
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
|
||||
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
|
||||
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
|
||||
@@ -1,9 +0,0 @@
|
||||
*
|
||||
!go.mod
|
||||
!go.sum
|
||||
!cursor-tab-server/
|
||||
!cursor-tab-server/**
|
||||
!gen/
|
||||
!gen/**
|
||||
!internal/
|
||||
!internal/**
|
||||
@@ -1,12 +0,0 @@
|
||||
## 变更说明 / What
|
||||
|
||||
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
|
||||
|
||||
## 关联 Issue / Related Issue
|
||||
|
||||
<!-- 例如 / e.g. Closes #161 -->
|
||||
|
||||
## 测试方式 / How to Test
|
||||
|
||||
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
|
||||
|
||||
+7
-8
@@ -1,4 +1,5 @@
|
||||
claude-server.tar
|
||||
.DS_Store
|
||||
dist
|
||||
.task
|
||||
bin
|
||||
@@ -8,7 +9,12 @@ logs.zip
|
||||
frontend/bindings
|
||||
dist
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
cursor-server.tar
|
||||
/cursor-server/target/
|
||||
/cursor-server/*.db
|
||||
/cursor-server/*.db-shm
|
||||
/cursor-server/*.db-wal
|
||||
server-node/cursor.tar
|
||||
server-go/cursor.tar
|
||||
server-go/log/
|
||||
@@ -16,12 +22,5 @@ server-go/log/
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
proto/extensions-cursor-app/
|
||||
ads-server-linux-amd64.tar
|
||||
cmd/ads-server/*.db
|
||||
cmd/ads-server/*.db-*
|
||||
cmd/ads-server/*.sqlite
|
||||
cmd/ads-server/*.sqlite-*
|
||||
cmd/ads-server/data/
|
||||
cmd/ads-server/ads-server
|
||||
cmd/ads-server/ads-server-linux-amd64.tar
|
||||
cursor-tab-server/cursor-tab-server-linux-amd64.tar
|
||||
/cursor-proto/proto/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
|
||||
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
|
||||
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
|
||||
- Keep components modular and concerns clearly separated.
|
||||
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
|
||||
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
|
||||
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
|
||||
@@ -1,90 +0,0 @@
|
||||
# 贡献指南
|
||||
|
||||
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
|
||||
|
||||
感谢你考虑为 cursor-byok 做出贡献!
|
||||
|
||||
## 开发环境
|
||||
|
||||
| 依赖 | 版本要求 |
|
||||
|------|---------|
|
||||
| Go | >= 1.25 |
|
||||
| Node.js | >= 20 |
|
||||
| Yarn | 1.x (classic) |
|
||||
| [Task](https://taskfile.dev) | >= 3 |
|
||||
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
|
||||
|
||||
Linux 额外依赖:`libgtk-3-dev`、`libwebkit2gtk-4.1-dev`(Wails 运行时需要)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
cd frontend && yarn install --frozen-lockfile && cd ..
|
||||
|
||||
# 启动开发模式(热重载)
|
||||
task dev
|
||||
|
||||
# 构建当前平台分发包
|
||||
task build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── main.go # 入口
|
||||
├── internal/ # Go 后端(代理、转发、客户端管理等)
|
||||
├── frontend/ # Vue 3 + Vite + Tailwind 前端
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU)
|
||||
│ │ └── state/ # 全局状态
|
||||
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
|
||||
├── prompt/ # 内置 Agent prompt 模板
|
||||
├── proto/ # Protobuf 定义
|
||||
├── build/ # 构建配置与平台 Taskfile
|
||||
├── scripts/ # 辅助脚本(release、metrics)
|
||||
└── Taskfile.yml # 顶层任务编排
|
||||
```
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 提交信息
|
||||
|
||||
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
|
||||
|
||||
```
|
||||
feat(proxy): 支持自定义 upstream 超时
|
||||
fix(i18n): 补全日语翻译缺失 key
|
||||
release: 0.0.42
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
|
||||
- 前端:Vue SFC + Composition API,Tailwind 工具类优先。
|
||||
- 新增 UI 文案必须同步更新所有 locale 文件(`frontend/src/i18n/locales/`)。
|
||||
|
||||
### 分支与 PR
|
||||
|
||||
1. 从 `main` 创建功能分支:`feat/xxx`、`fix/xxx`。
|
||||
2. 保持 PR 小而聚焦,一个 PR 解决一个问题。
|
||||
3. PR 描述中说明动机和测试方式。
|
||||
|
||||
## 构建与发布
|
||||
|
||||
```bash
|
||||
# 构建全平台(仅 macOS 主机)
|
||||
task build:all
|
||||
|
||||
# 准备发布资产
|
||||
task release:prepare
|
||||
|
||||
# 发布到 GitHub Releases
|
||||
task release:github
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
提交代码即表示你同意以 [MIT License](./LICENSE) 授权你的贡献。
|
||||
@@ -1,90 +0,0 @@
|
||||
# Contributing Guide
|
||||
|
||||
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
|
||||
Thank you for considering contributing to cursor-byok!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Dependency | Version |
|
||||
|------------|---------|
|
||||
| Go | >= 1.25 |
|
||||
| Node.js | >= 20 |
|
||||
| Yarn | 1.x (classic) |
|
||||
| [Task](https://taskfile.dev) | >= 3 |
|
||||
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
|
||||
|
||||
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
cd frontend && yarn install --frozen-lockfile && cd ..
|
||||
|
||||
# Start dev mode (hot reload)
|
||||
task dev
|
||||
|
||||
# Build for current platform
|
||||
task build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── main.go # Entry point
|
||||
├── internal/ # Go backend (proxy, forwarding, client management)
|
||||
├── frontend/ # Vue 3 + Vite + Tailwind frontend
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # Pages
|
||||
│ │ ├── components/ # Components
|
||||
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
|
||||
│ │ └── state/ # Global state
|
||||
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
|
||||
├── prompt/ # Built-in agent prompt templates
|
||||
├── proto/ # Protobuf definitions
|
||||
├── build/ # Build configs & platform Taskfiles
|
||||
├── scripts/ # Helper scripts (release, metrics)
|
||||
└── Taskfile.yml # Top-level task orchestration
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(proxy): support custom upstream timeout
|
||||
fix(i18n): add missing Japanese translation keys
|
||||
release: 0.0.42
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Go: follow `gofmt` / `go vet`; no additional linter config.
|
||||
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
|
||||
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
|
||||
|
||||
### Branching & PRs
|
||||
|
||||
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
|
||||
2. Keep PRs small and focused — one problem per PR.
|
||||
3. Describe motivation and how to test in the PR description.
|
||||
|
||||
## Build & Release
|
||||
|
||||
```bash
|
||||
# Build all platforms (macOS host only)
|
||||
task build:all
|
||||
|
||||
# Prepare release assets
|
||||
task release:prepare
|
||||
|
||||
# Publish to GitHub Releases
|
||||
task release:github
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,87 @@
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
|
||||
# cursor-byok
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
|
||||
`cursor-byok` 是一个基于真实 Cursor Agent 流量与 protobuf 实现的自托管服务端,用于把 Cursor 客户端接入用户指定的 LLM Provider。
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
当前 Rust 服务 `cursor-server` 已实现:
|
||||
|
||||
- Cursor `RunSSE + BidiAppend` 双向协议与 Connect envelope。
|
||||
- OpenAI Chat、OpenAI Responses、Anthropic 三种无状态流式端点。
|
||||
- `LLM → 客户端工具 → 结果提交 → 下一轮 LLM` 的通用 Loop。
|
||||
- append-only canonical messages、不可变 revision 分支和同对话 Run 抢占。
|
||||
- Cursor typed tool UI、Exec/Interaction、多阶段编辑、MCP 与子代理。
|
||||
- Blob CAS、KV GET/SET ACK、两阶段 checkpoint 和 pending ToolRound 恢复。
|
||||
- 未匹配 Cursor backend 路由原样流式转发到上游。
|
||||
- React + Vite + TypeScript + Tailwind 管理台,支持 Provider 配置、模型发现和调用明细。
|
||||
- 每次 Provider 调用的时间、模型快照、状态和 authoritative usage;详细模式保存脱敏请求与原始流响应。
|
||||
|
||||
## 交流群组
|
||||
https://t.me/cursor_byok
|
||||
启动方式见 [cursor-server/README.md](./cursor-server/README.md)。协议证据见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md),当前实现约束见 [一次性重构计划计划.md](./docs/一次性重构计划计划.md)。
|
||||
|
||||
## 核心数据流
|
||||
|
||||
## 为什么做这个项目
|
||||
```text
|
||||
HTTP / Connect
|
||||
↓
|
||||
Cursor adapter
|
||||
↓ ClientCommand / ClientEvent
|
||||
RunEngine
|
||||
↓
|
||||
canonical messages + selected revision
|
||||
↓
|
||||
typed ModelRequest
|
||||
↓
|
||||
Provider adapter → HTTP/SSE → ModelEvent
|
||||
```
|
||||
|
||||
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。
|
||||
Loop 不依赖 Cursor protobuf、Blob、checkpoint、数字 wire id 或具体 Provider JSON。Cursor adapter 和 Provider adapter 只在各自边界做协议投射。
|
||||
|
||||
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
|
||||
## 状态与 checkpoint
|
||||
|
||||
这个项目的目标,是让模型选择权重新回到用户手里。
|
||||
- SQLite 中的 immutable messages 与 revision 父链是对话事实源;回滚只选择旧 revision 并建立新分支。
|
||||
- ToolRound 保存完整 assistant、原始 call 顺序和真实 result 完成顺序;结果未齐时不会把悬空 tool call 投给下一轮模型。
|
||||
- BlobID 是 `SHA-256(data)`;Blob 类型来自引用字段,不编码在 ID 中。
|
||||
- checkpoint 引用的新 Blob 必须先收到对应 KV SET ACK。协议中不存在 checkpoint ACK,也不保存跨流 outbox。
|
||||
- staged checkpoint 内联完整 pending assistant;ToolRound 全部结果提交后才折叠进 stable roots。抓包没有单 ToolResult checkpoint,因此实现也不制造该状态。
|
||||
- settled checkpoint 必须先于下一轮 LLM 调用。最终文本轮严格发送 `turn_ended → staged → settled → settled 重发 → EndStream`。
|
||||
- Cursor.app 只把 `turn_ended` 前的 checkpoint 作为自动恢复候选;恢复 pending assistant 时先继续工具,不重复调用 LLM。
|
||||
|
||||
## 路线图
|
||||
## 目录边界
|
||||
|
||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
[详细使用教程](https://docs.leokun.cn)
|
||||
```text
|
||||
cursor-server/src/
|
||||
├── control/ # 客户端无关的 Provider、模型、调用观测 HTTP API
|
||||
├── client/ # 所有客户端共用的最小 command/event port
|
||||
├── model/ # canonical message、revision、ModelSpec、typed history
|
||||
├── run/ # 协议无关 Loop、ModelCycle、ToolRound 和 RunRegistry
|
||||
├── provider/ # 各 LLM 端点请求与流事件适配
|
||||
├── cursor/
|
||||
│ ├── request/ # RunRequest → PreparedRun/CursorRunContext
|
||||
│ ├── prompting/ # Cursor prompt、工具 catalog 和 mode manifest
|
||||
│ ├── projection/ # Cursor AI-SDK stable/pending JSON 编解码
|
||||
│ ├── interaction/ # UI 更新、InteractionQuery、typed ToolCall 渲染
|
||||
│ ├── tools/ # Cursor 工具 transport、runtime、dispatch 和 result
|
||||
│ └── checkpoint/ # root/Turn/derived/recovery 与串行 worker
|
||||
└── store/ # SQLite messages、revision、ToolRound、Run 和 Blob CAS
|
||||
|
||||
## 后续
|
||||
console/ # React 管理台;只依赖 control API,不依赖 Cursor protobuf
|
||||
```
|
||||
|
||||
后续会继续扩展更多工具和使用场景,包括但不限于:
|
||||
详细到文件的目标目录和验收项只在重构计划中维护,README 不复制第二份易漂移的完整文件清单。
|
||||
|
||||
- 支持更多 IDE 接入
|
||||
- 支持更多 Chat 类应用
|
||||
- 支持更多 Agent 工具和工作流
|
||||
- 提供更完善的自托管部署方式
|
||||
- 持续优化不同模型 API 的兼容性
|
||||
- 降低接入成本,让已有模型额度可以被更充分地利用
|
||||
|
||||
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
|
||||
## 工程原则
|
||||
|
||||
- 不保留旧路径、兼容层或失败后的隐式 fallback。
|
||||
- 同一状态只有一个所有者;协议层不做 Loop 决策。
|
||||
- PromptSpec、ModelSpec 和 selected revision 决定可重放的 ModelRequest;request id、时间和 model call id 不进入模型输入。
|
||||
- 前缀稳定限定在相同 PromptSpec/ModelSpec/Provider route;新 Run 切换模型或模式时只替换 Cursor system root,其他历史 message roots 继续复用。
|
||||
- Provider replay state 只回传给产生它的端点;可展示 thinking 不是跨端点 reasoning 字段。
|
||||
- Provider usage 只采用端点报告的单轮最终值,不自行估算。
|
||||
- 同一个取消信号覆盖等待 HTTP 响应头和读取 SSE 两段。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd cursor-server
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
-569
@@ -1,569 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
|
||||
vars:
|
||||
APP_NAME: "Cursor助手"
|
||||
BIN_DIR: "bin"
|
||||
APP_VERSION:
|
||||
sh: 'go run ./scripts/release version -config ./build/config.yml'
|
||||
RELEASE_REPO: "leookun/cursor-byok"
|
||||
RELEASE_BASE_NAME: "cursor-byok"
|
||||
RELEASE_SOURCE_PATH: "release-notes.md"
|
||||
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
|
||||
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
SCAN: '{{.SCAN | default "false"}}'
|
||||
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
|
||||
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
|
||||
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
|
||||
CURRENT_LINUX_ARCH: "amd64"
|
||||
CURRENT_LINUX_NAME: "linux-amd64"
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建当前系统分发包
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
|
||||
cmds:
|
||||
- task: clean:dist
|
||||
- task: build:current
|
||||
|
||||
run:
|
||||
summary: 运行当前系统版本
|
||||
cmds:
|
||||
- task: "{{OS}}:run"
|
||||
|
||||
build:all:
|
||||
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ]'
|
||||
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
|
||||
cmds:
|
||||
- task: clean:dist
|
||||
- task: build:darwin:arm64
|
||||
- task: build:darwin:amd64
|
||||
- task: build:windows:386
|
||||
- task: build:windows:amd64
|
||||
|
||||
build:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
|
||||
|
||||
build:darwin:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: '{{ARCH}}'
|
||||
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
|
||||
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
|
||||
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:darwin:arm64:
|
||||
internal: false
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: macos-arm64
|
||||
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
|
||||
APP_BUNDLE: macos-arm64.app
|
||||
DMG_NAME: macos-arm64.dmg
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:darwin:amd64:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:dmg
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: macos-intel
|
||||
OUTPUT: '{{.BIN_DIR}}/macos-intel'
|
||||
APP_BUNDLE: macos-intel.app
|
||||
DMG_NAME: macos-intel.dmg
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
|
||||
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:386:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: 386
|
||||
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
|
||||
ZIP_NAME: windows-32.zip
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:windows:amd64:
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
|
||||
ZIP_NAME: windows-64.zip
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:linux:current:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: '{{.CURRENT_LINUX_ARCH}}'
|
||||
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
build:linux:amd64:
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: linux-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
cursor-tab-server:docker:amd64:
|
||||
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
|
||||
vars:
|
||||
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
|
||||
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
|
||||
CONTEXT_DIR: cursor-tab-server
|
||||
DOCKERFILE: cursor-tab-server/Dockerfile
|
||||
preconditions:
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: test -f "{{.DOCKERFILE}}"
|
||||
msg: "未找到 cursor-tab-server/Dockerfile。"
|
||||
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
|
||||
msg: "未找到 cursor-tab-server/go.mod。"
|
||||
cmds:
|
||||
- mkdir -p "{{.CONTEXT_DIR}}"
|
||||
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
|
||||
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
|
||||
- ls -lh "{{.IMAGE_TAR}}"
|
||||
|
||||
clean:dist:
|
||||
internal: true
|
||||
cmds:
|
||||
- mkdir -p "{{.BIN_DIR}}"
|
||||
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
|
||||
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
|
||||
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
|
||||
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||
|
||||
dev:
|
||||
summary: 启动开发模式(前台,终端会保持占用)
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
proxy-debugger:
|
||||
summary: 启动独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go run ./cmd/cursor-proxy-debugger
|
||||
|
||||
proxy-debugger:build:
|
||||
summary: 构建独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
|
||||
ads:install:
|
||||
summary: 安装广告页依赖
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules
|
||||
cmds:
|
||||
- yarn install --frozen-lockfile
|
||||
|
||||
ads:dev:
|
||||
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/)
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
deps:
|
||||
- task: ads:install
|
||||
cmds:
|
||||
- yarn dev
|
||||
|
||||
ads:build:
|
||||
summary: 构建广告 ZIP 产物
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
deps:
|
||||
- task: ads:install
|
||||
cmds:
|
||||
- yarn build
|
||||
|
||||
setup:docker:
|
||||
summary: 构建 Linux 交叉编译 Docker 镜像
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
release:clean:
|
||||
summary: 清理当前版本的发布目录
|
||||
cmds:
|
||||
- rm -rf "{{.RELEASE_DIR}}"
|
||||
- mkdir -p "{{.RELEASE_DIR}}"
|
||||
|
||||
release:ensure:dir:
|
||||
internal: true
|
||||
cmds:
|
||||
- mkdir -p "{{.RELEASE_DIR}}"
|
||||
|
||||
release:notes:
|
||||
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
|
||||
internal: true
|
||||
preconditions:
|
||||
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
|
||||
msg: "未找到 release-notes.md,请先创建发布日志文件。"
|
||||
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
|
||||
msg: "release-notes.md 不能为空,请先填写发布日志。"
|
||||
cmds:
|
||||
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
|
||||
|
||||
release:build:macos:arm64:
|
||||
cmds:
|
||||
- task: darwin:package:archive
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: cursor-release-macos-arm64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
|
||||
APP_BUNDLE: '{{.APP_NAME}}.app'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:macos:amd64:
|
||||
internal: true
|
||||
cmds:
|
||||
- task: darwin:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: cursor-release-macos-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
|
||||
APP_BUNDLE: '{{.APP_NAME}}.app'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:windows:amd64:
|
||||
cmds:
|
||||
- task: windows:create:zip
|
||||
vars:
|
||||
ARCH: amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
|
||||
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:build:linux:amd64:
|
||||
cmds:
|
||||
- task: linux:package:archive
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: cursor-release-linux-amd64
|
||||
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
|
||||
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
|
||||
SCAN: '{{.SCAN}}'
|
||||
|
||||
release:manifest:
|
||||
summary: 生成 update.json
|
||||
internal: true
|
||||
cmds:
|
||||
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
|
||||
|
||||
release:prepare:
|
||||
summary: 在当前主机上生成对应的发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
|
||||
|
||||
release:prepare:darwin:
|
||||
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ]'
|
||||
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: release:clean
|
||||
- task: common:update:build-assets
|
||||
- task: release:notes
|
||||
- task: release:build:macos:arm64
|
||||
- task: release:build:macos:amd64
|
||||
- task: release:build:windows:amd64
|
||||
- task: release:build:linux:amd64
|
||||
|
||||
release:prepare:linux:
|
||||
summary: 在当前主机上生成 Linux 发布资产
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
|
||||
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
|
||||
- sh: wails3 version >/dev/null 2>&1
|
||||
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
|
||||
cmds:
|
||||
- task: release:ensure:dir
|
||||
- task: common:update:build-assets
|
||||
- task: release:notes
|
||||
- task: release:build:linux:amd64
|
||||
|
||||
release:verify:assets:
|
||||
summary: 验证当前版本发布资产是否齐全
|
||||
preconditions:
|
||||
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
|
||||
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
|
||||
msg: "缺少 macOS arm64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
|
||||
msg: "缺少 macOS amd64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
|
||||
msg: "缺少 Windows amd64 发布资产。"
|
||||
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
|
||||
msg: "缺少 Linux amd64 发布资产。"
|
||||
cmds:
|
||||
- echo "release assets verified"
|
||||
|
||||
release:sync:readme:
|
||||
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md)
|
||||
internal: true
|
||||
env:
|
||||
GH_PAGER: cat
|
||||
GIT_PAGER: cat
|
||||
PAGER: cat
|
||||
preconditions:
|
||||
- sh: gh --version >/dev/null 2>&1
|
||||
msg: "未检测到 gh,请先安装 GitHub CLI。"
|
||||
- sh: gh auth token >/dev/null 2>&1
|
||||
msg: "gh 未登录,请先执行 gh auth login。"
|
||||
cmds:
|
||||
- |
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
|
||||
|
||||
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return completed
|
||||
stderr = (completed.stderr or "").strip()
|
||||
stdout = (completed.stdout or "").strip()
|
||||
combined = f"{stderr}\n{stdout}".strip()
|
||||
if allow_404 and "404" in combined:
|
||||
return completed
|
||||
last = completed
|
||||
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
|
||||
return completed
|
||||
if attempt < retries:
|
||||
time.sleep(min(2 ** (attempt - 1), 5))
|
||||
return last
|
||||
|
||||
repo = "{{.RELEASE_REPO}}"
|
||||
readme_path = pathlib.Path("README.md")
|
||||
content = readme_path.read_bytes()
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
|
||||
get_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/contents/README.md",
|
||||
]
|
||||
result = run_gh(get_cmd, allow_404=True)
|
||||
|
||||
payload = {
|
||||
"message": "docs: sync README from source repo",
|
||||
"content": encoded,
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
if result.returncode == 0:
|
||||
existing = json.loads(result.stdout)
|
||||
existing_content = base64.b64decode(existing["content"])
|
||||
if existing_content == content:
|
||||
sys.exit(0)
|
||||
payload["sha"] = existing["sha"]
|
||||
|
||||
put_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/contents/README.md",
|
||||
"--method", "PUT",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(put_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0 and completed.stderr:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
PY
|
||||
|
||||
release:github:
|
||||
summary: 发布当前版本到 GitHub Releases
|
||||
env:
|
||||
GH_PAGER: cat
|
||||
GIT_PAGER: cat
|
||||
PAGER: cat
|
||||
preconditions:
|
||||
- sh: gh --version >/dev/null 2>&1
|
||||
msg: "未检测到 gh,请先安装 GitHub CLI。"
|
||||
- sh: gh auth token >/dev/null 2>&1
|
||||
msg: "gh 未登录,请先执行 gh auth login。"
|
||||
cmds:
|
||||
- task: release:verify:assets
|
||||
- task: release:manifest
|
||||
- task: release:sync:readme
|
||||
- |
|
||||
python3 - <<'PY'
|
||||
import mimetypes
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
|
||||
|
||||
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
return completed
|
||||
stderr = (completed.stderr or "").strip()
|
||||
stdout = (completed.stdout or "").strip()
|
||||
combined = f"{stderr}\n{stdout}".strip()
|
||||
if allow_404 and "404" in combined:
|
||||
return completed
|
||||
last = completed
|
||||
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
|
||||
return completed
|
||||
if attempt < retries:
|
||||
time.sleep(min(2 ** (attempt - 1), 5))
|
||||
return last
|
||||
|
||||
repo = "{{.RELEASE_REPO}}"
|
||||
version = "{{.APP_VERSION}}"
|
||||
tag = f"v{version}"
|
||||
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
|
||||
assets = sorted([
|
||||
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
|
||||
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
|
||||
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
|
||||
])
|
||||
|
||||
get_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/tags/{tag}",
|
||||
]
|
||||
result = run_gh(get_cmd, allow_404=True)
|
||||
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": "main",
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
|
||||
if result.returncode == 0:
|
||||
release = json.loads(result.stdout)
|
||||
release_id = release["id"]
|
||||
edit_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/{release_id}",
|
||||
"--method", "PATCH",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0:
|
||||
if completed.stderr:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
refreshed = run_gh(get_cmd)
|
||||
if refreshed.returncode != 0:
|
||||
if refreshed.stderr:
|
||||
sys.stderr.write(refreshed.stderr)
|
||||
sys.exit(refreshed.returncode)
|
||||
release = json.loads(refreshed.stdout)
|
||||
else:
|
||||
create_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases",
|
||||
"--method", "POST",
|
||||
"--input", "-",
|
||||
]
|
||||
completed = run_gh(create_cmd, input_text=json.dumps(payload))
|
||||
if completed.returncode != 0:
|
||||
sys.stderr.write(completed.stderr)
|
||||
sys.exit(completed.returncode)
|
||||
release = json.loads(completed.stdout)
|
||||
|
||||
upload_url = release["upload_url"].split("{", 1)[0]
|
||||
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
|
||||
|
||||
for asset in assets:
|
||||
name = asset.name
|
||||
if name in existing:
|
||||
delete_cmd = [
|
||||
"gh", "api",
|
||||
f"repos/{repo}/releases/assets/{existing[name]}",
|
||||
"--method", "DELETE",
|
||||
]
|
||||
deleted = run_gh(delete_cmd)
|
||||
if deleted.returncode != 0:
|
||||
if deleted.stderr:
|
||||
sys.stderr.write(deleted.stderr)
|
||||
sys.exit(deleted.returncode)
|
||||
|
||||
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
upload_cmd = [
|
||||
"gh", "api",
|
||||
f"{upload_url}?name={name}",
|
||||
"--method", "POST",
|
||||
"--header", f"Content-Type: {content_type}",
|
||||
"--input", str(asset),
|
||||
]
|
||||
uploaded = run_gh(upload_cmd)
|
||||
if uploaded.returncode != 0:
|
||||
if uploaded.stderr:
|
||||
sys.stderr.write(uploaded.stderr)
|
||||
sys.exit(uploaded.returncode)
|
||||
PY
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User') + ';$HOME\go\bin'; task build"
|
||||
pause
|
||||
@@ -1,2 +0,0 @@
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
|
||||
task build
|
||||
@@ -1,160 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
ROOT_DIR:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}/.." && pwd'
|
||||
BUILD_DIR:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}" && pwd'
|
||||
|
||||
tasks:
|
||||
extract:proto:
|
||||
summary: 从 Cursor 扩展快照提取 proto
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
preconditions:
|
||||
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
|
||||
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
|
||||
- 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}}'
|
||||
|
||||
sync:proto:
|
||||
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
deps:
|
||||
- task: extract:proto
|
||||
cmds:
|
||||
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
|
||||
- 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}}'
|
||||
sources:
|
||||
- proto/*.proto
|
||||
generates:
|
||||
- gen/agentv1/*.go
|
||||
- gen/aiserverv1/*.go
|
||||
- gen/aiserverv1/aiserverv1connect/*.go
|
||||
preconditions:
|
||||
- sh: protoc --version
|
||||
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
|
||||
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
|
||||
msg: "未检测到 protoc-gen-go,请先安装。"
|
||||
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
|
||||
msg: "未检测到 protoc-gen-connect-go,请先安装。"
|
||||
cmds:
|
||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
|
||||
- gofmt -w $(find ./gen -name '*.go' -type f)
|
||||
|
||||
go:mod:tidy:
|
||||
summary: 整理 Go 依赖
|
||||
internal: true
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
install:frontend:deps:
|
||||
summary: 安装前端依赖(Yarn)
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
sources:
|
||||
- package.json
|
||||
- yarn.lock
|
||||
generates:
|
||||
- node_modules
|
||||
preconditions:
|
||||
- sh: yarn --version
|
||||
msg: "未检测到 Yarn,请先安装 Yarn。"
|
||||
cmds:
|
||||
- yarn install --frozen-lockfile
|
||||
|
||||
generate:bindings:
|
||||
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||
summary: 生成前端绑定
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
deps:
|
||||
- task: generate:proto
|
||||
- task: go:mod:tidy
|
||||
sources:
|
||||
- "**/*.[jt]s"
|
||||
- exclude: frontend/**/*
|
||||
- frontend/bindings/**/*
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
generates:
|
||||
- frontend/bindings/**/*
|
||||
cmds:
|
||||
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
|
||||
|
||||
build:frontend:
|
||||
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
|
||||
summary: 构建前端资源
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
sources:
|
||||
- "**/*"
|
||||
generates:
|
||||
- dist/**/*
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
- task: generate:bindings
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
cmds:
|
||||
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
|
||||
env:
|
||||
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||
vars:
|
||||
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||
SCAN: '{{.SCAN | default "false"}}'
|
||||
|
||||
dev:frontend:
|
||||
summary: 启动前端开发服务
|
||||
dir: '{{.ROOT_DIR}}/frontend'
|
||||
deps:
|
||||
- task: install:frontend:deps
|
||||
cmds:
|
||||
- yarn run dev --port {{.VITE_PORT}} --strictPort
|
||||
|
||||
generate:icons:
|
||||
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
|
||||
dir: '{{.BUILD_DIR}}'
|
||||
sources:
|
||||
- "appicon.png"
|
||||
generates:
|
||||
- "darwin/icons.icns"
|
||||
- "windows/icon.ico"
|
||||
cmds:
|
||||
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
|
||||
|
||||
update:build-assets:
|
||||
summary: 根据配置更新构建资产
|
||||
dir: '{{.BUILD_DIR}}'
|
||||
cmds:
|
||||
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||
- rm -rf ios
|
||||
|
||||
setup:docker:
|
||||
summary: 构建 Linux 交叉编译 Docker 镜像
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
preconditions:
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: test -f ./build/docker/Dockerfile.cross
|
||||
msg: "未找到 build/docker/Dockerfile.cross。"
|
||||
cmds:
|
||||
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
@@ -1,43 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
# 构建元信息(修改后可执行:wails3 task common:update:build-assets)
|
||||
info:
|
||||
companyName: "Cursor助手"
|
||||
productName: "Cursor助手"
|
||||
productIdentifier: "com.cursor.wuxianxubei"
|
||||
description: "Cursor助手"
|
||||
copyright: "© 2026, Cursor助手"
|
||||
comments: "Cursor助手"
|
||||
version: "0.0.46"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
log_level: warn
|
||||
debounce: 1000
|
||||
ignore:
|
||||
dir:
|
||||
- .git
|
||||
- node_modules
|
||||
- frontend
|
||||
- bin
|
||||
file:
|
||||
- .DS_Store
|
||||
- .gitignore
|
||||
- .gitkeep
|
||||
watched_extension:
|
||||
- "*.go"
|
||||
- "*.js"
|
||||
- "*.ts"
|
||||
git_ignore: true
|
||||
executes:
|
||||
- cmd: wails3 build DEV=true
|
||||
type: blocking
|
||||
- cmd: wails3 task common:dev:frontend
|
||||
type: background
|
||||
- cmd: wails3 task run
|
||||
type: primary
|
||||
|
||||
fileAssociations:
|
||||
|
||||
other:
|
||||
- name: 其他数据
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icons</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>appicon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.cursor.wuxianxubei</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.46</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.46</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Cursor助手</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,32 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icons</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>appicon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.cursor.wuxianxubei</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cursor助手</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.46</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.46</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026, Cursor助手</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,273 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 macOS 程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:native
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用本机构建 macOS 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: common:generate:icons
|
||||
cmds:
|
||||
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
|
||||
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||
env:
|
||||
GOOS: darwin
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
CGO_CFLAGS: "-mmacosx-version-min=10.15"
|
||||
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
|
||||
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||
|
||||
build:universal:
|
||||
summary: 构建 macOS 通用包(amd64 + arm64)
|
||||
internal: true
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH: amd64
|
||||
BINARY_NAME: "{{.APP_NAME}}-amd64"
|
||||
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
|
||||
- task: build
|
||||
vars:
|
||||
ARCH: arm64
|
||||
BINARY_NAME: "{{.APP_NAME}}-arm64"
|
||||
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
cmds:
|
||||
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||
|
||||
package:
|
||||
summary: 打包 macOS .app(可通过 APP_BUNDLE 指定输出包名)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:app:bundle
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
|
||||
package:dmg:
|
||||
summary: 打包 macOS DMG(可通过 APP_BUNDLE/DMG_NAME 指定输出文件名)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: package
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:dmg
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
DMG_NAME:
|
||||
ref: .DMG_NAME
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
package:archive:
|
||||
summary: 打包 macOS updater 归档(.tar.gz)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: package
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:archive
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
ARCHIVE_PATH:
|
||||
ref: .ARCHIVE_PATH
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
package:universal:
|
||||
summary: 打包 macOS 通用 .app
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:universal
|
||||
- task: create:app:bundle
|
||||
|
||||
create:app:bundle:
|
||||
summary: 生成 .app 包
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
|
||||
cmds:
|
||||
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
|
||||
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
|
||||
- cp "{{.BINARY_PATH}}" "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS/{{.EXECUTABLE_NAME}}"
|
||||
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents"
|
||||
- task: codesign:adhoc
|
||||
vars:
|
||||
APP_BUNDLE:
|
||||
ref: .APP_BUNDLE
|
||||
|
||||
codesign:adhoc:
|
||||
summary: 使用临时签名(开发测试)
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
cmds:
|
||||
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
|
||||
create:dmg:
|
||||
summary: 生成 macOS DMG 包(优先使用 create-dmg,不存在时回退 hdiutil)
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
DMG_NAME: '{{.DMG_NAME | default (printf "%s.dmg" .APP_NAME)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.dmg-{{.ARCH | default ARCH}}'
|
||||
cmds:
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp -R "{{.BIN_DIR}}/{{.APP_BUNDLE}}" "{{.STAGING_DIR}}/{{.APP_NAME}}.app"
|
||||
- |
|
||||
if [ -f "build/dmg-extras/提示损坏?点我.command" ]; then
|
||||
cp "build/dmg-extras/提示损坏?点我.command" "{{.STAGING_DIR}}/提示损坏?点我.command"
|
||||
chmod +x "{{.STAGING_DIR}}/提示损坏?点我.command"
|
||||
fi
|
||||
- |
|
||||
if command -v create-dmg >/dev/null 2>&1; then
|
||||
rm -f "{{.BIN_DIR}}/{{.DMG_NAME}}"
|
||||
create-dmg --volname "{{.APP_NAME}}" --window-size 600 400 --icon-size 100 --app-drop-link 450 200 "{{.BIN_DIR}}/{{.DMG_NAME}}" "{{.STAGING_DIR}}"
|
||||
else
|
||||
hdiutil create -volname "{{.APP_NAME}}" -srcfolder "{{.STAGING_DIR}}" -ov -format UDZO "{{.BIN_DIR}}/{{.DMG_NAME}}"
|
||||
fi
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
|
||||
create:archive:
|
||||
summary: 生成 macOS updater tar.gz 归档
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.ARCHIVE_PATH}}"
|
||||
- env LC_ALL=C tar -C "{{.BIN_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.APP_BUNDLE}}"
|
||||
|
||||
cleanup:package:artifacts:
|
||||
summary: 清理打包中间产物
|
||||
internal: true
|
||||
vars:
|
||||
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.BINARY_PATH}}"
|
||||
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
|
||||
|
||||
run:
|
||||
summary: 运行本地开发包
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
|
||||
cmds:
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||
- cp "{{.BIN_DIR}}/{{.BINARY_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}"
|
||||
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
|
||||
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}'
|
||||
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
APP_PATH="/Applications/Cursor助手.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "请先把 Cursor助手 拖到“应用程序”目录后再运行本脚本。"
|
||||
read -r -p "按回车退出..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在移除隔离属性: $APP_PATH"
|
||||
xattr -cr "$APP_PATH"
|
||||
echo "处理完成,现在可以正常打开 Cursor助手。"
|
||||
read -r -p "按回车退出..."
|
||||
@@ -1,26 +0,0 @@
|
||||
# Docker image for building this app's Linux amd64 binary from macOS/Linux hosts.
|
||||
|
||||
ARG BASE_IMAGE=debian:trixie
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git nodejs yarnpkg pkg-config gcc libc6-dev \
|
||||
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
&& curl -fsSL "${GO_TARBALL_URL}" -o /tmp/go.tar.gz \
|
||||
&& rm -rf /usr/local/go \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
|
||||
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt \
|
||||
&& ln -sf /usr/bin/yarnpkg /usr/local/bin/yarn \
|
||||
&& rm -f /tmp/go.tar.gz \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY build-linux.sh /usr/local/bin/build-linux.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/build-linux.sh
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/usr/local/bin/build-linux.sh"]
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
log() {
|
||||
printf '[linux-build] %s\n' "$*"
|
||||
}
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=1
|
||||
export CGO_CFLAGS="-w"
|
||||
|
||||
APP=${APP_NAME:-$(basename "$(pwd)")}
|
||||
BIN_PATH="bin/${APP}-linux-amd64"
|
||||
LOG_MODE=${GO_BUILD_LOG_MODE:-quiet}
|
||||
|
||||
case "$LOG_MODE" in
|
||||
quiet|heartbeat)
|
||||
;;
|
||||
verbose|trace)
|
||||
log "workspace: $(pwd)"
|
||||
log "target: ${APP} (${GOOS}/${GOARCH})"
|
||||
log "go version: $(go version)"
|
||||
log "log mode: ${LOG_MODE}"
|
||||
;;
|
||||
*)
|
||||
log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet"
|
||||
LOG_MODE="quiet"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
|
||||
log "frontend/dist missing, building frontend assets in container"
|
||||
(
|
||||
cd frontend
|
||||
yarn install --frozen-lockfile
|
||||
yarn run build
|
||||
)
|
||||
fi
|
||||
|
||||
mkdir -p bin
|
||||
|
||||
LDFLAGS="-s -w"
|
||||
if [ -n "${EXTRA_LDFLAGS:-}" ]; then
|
||||
LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
|
||||
fi
|
||||
|
||||
TAGS="production"
|
||||
if [ -n "${EXTRA_TAGS:-}" ]; then
|
||||
TAGS="${TAGS},${EXTRA_TAGS}"
|
||||
fi
|
||||
|
||||
set -- go build
|
||||
case "$LOG_MODE" in
|
||||
quiet)
|
||||
;;
|
||||
heartbeat)
|
||||
;;
|
||||
verbose)
|
||||
set -- "$@" -v
|
||||
;;
|
||||
trace)
|
||||
set -- "$@" -x -v
|
||||
;;
|
||||
esac
|
||||
|
||||
set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" .
|
||||
|
||||
if "$@"; then
|
||||
:
|
||||
else
|
||||
status="$?"
|
||||
log "go build failed with exit code ${status}"
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
case "$LOG_MODE" in
|
||||
verbose|trace)
|
||||
log "built: ${BIN_PATH}"
|
||||
;;
|
||||
esac
|
||||
@@ -1,9 +0,0 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
Object.freeze($Create.Events);
|
||||
@@ -1,2 +0,0 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
@@ -1,192 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
CROSS_IMAGE: cursor-linux-amd64-cross
|
||||
PROJECT_ROOT:
|
||||
sh: 'cd "{{.TASKFILE_DIR}}/../.." && pwd'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 Linux 程序
|
||||
internal: true
|
||||
vars:
|
||||
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
HAS_CC:
|
||||
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
|
||||
preconditions:
|
||||
- sh: '[ "{{.TARGET_ARCH}}" = "amd64" ]'
|
||||
msg: "当前仅支持 linux/amd64 构建。"
|
||||
cmds:
|
||||
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用本机构建 Linux 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ]'
|
||||
msg: "Linux 原生构建仅支持在 Linux 主机上执行。"
|
||||
- sh: pkg-config --version >/dev/null 2>&1
|
||||
msg: "未检测到 pkg-config,请先安装。"
|
||||
- sh: 'command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1'
|
||||
msg: "未检测到 gcc 或 clang,请先安装 C 编译器。"
|
||||
- sh: pkg-config --exists gtk+-3.0 webkit2gtk-4.1 gdk-3.0 gio-unix-2.0
|
||||
msg: "缺少 Linux GUI 构建依赖,请安装 gtk+-3.0、webkit2gtk-4.1、gdk-3.0 和 gio-unix-2.0 开发包。"
|
||||
cmds:
|
||||
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
|
||||
vars:
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
|
||||
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
|
||||
env:
|
||||
GOOS: linux
|
||||
CGO_ENABLED: 1
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
build:docker:
|
||||
summary: 使用 Docker 构建 Linux 程序
|
||||
internal: true
|
||||
dir: '{{.PROJECT_ROOT}}'
|
||||
deps:
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
|
||||
msg: "Docker Linux 构建当前仅支持在 macOS 或 Linux 主机上执行。"
|
||||
- sh: docker info >/dev/null 2>&1
|
||||
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
|
||||
- sh: docker image inspect {{.CROSS_IMAGE}} >/dev/null 2>&1
|
||||
msg: "未找到 Docker 镜像 '{{.CROSS_IMAGE}}',请先执行 task setup:docker。"
|
||||
cmds:
|
||||
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint /bin/sh -v "{{.PROJECT_ROOT}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e GOPATH=/go -e GOMODCACHE=/go/pkg/mod -e APP_NAME="{{.APP_NAME}}" -e EXTRA_LDFLAGS="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}" -e GO_BUILD_LOG_MODE="{{.GO_BUILD_LOG_MODE}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" /app/build/docker/build-linux.sh
|
||||
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint chown -v "{{.PROJECT_ROOT}}:/app" "{{.CROSS_IMAGE}}" -R $(id -u):$(id -g) /app/bin
|
||||
- mkdir -p "{{.BIN_DIR}}"
|
||||
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||
vars:
|
||||
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||
DOCKER_PLATFORM: 'linux/{{.DOCKER_ARCH}}'
|
||||
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||
GO_BUILD_LOG_MODE: '{{.GO_BUILD_LOG_MODE | default "quiet"}}'
|
||||
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
|
||||
GO_CACHE_MOUNT:
|
||||
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||
REPLACE_MOUNTS:
|
||||
sh: |
|
||||
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||
if [ "${path#/}" = "$path" ]; then
|
||||
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||
fi
|
||||
if [ -d "$path" ]; then
|
||||
echo "-v $path:$path:ro"
|
||||
fi
|
||||
done | tr '\n' ' '
|
||||
|
||||
package:archive:
|
||||
summary: 打包 Linux updater 归档(.tar.gz)
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: create:archive
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
ARCHIVE_BINARY_NAME:
|
||||
ref: .ARCHIVE_BINARY_NAME
|
||||
ARCHIVE_PATH:
|
||||
ref: .ARCHIVE_PATH
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
- task: cleanup:package:artifacts
|
||||
vars:
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BINARY_NAME:
|
||||
ref: .BINARY_NAME
|
||||
|
||||
create:archive:
|
||||
summary: 生成 Linux updater tar.gz 归档
|
||||
internal: true
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default .APP_NAME}}'
|
||||
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.linux-archive-{{.ARCH | default ARCH}}'
|
||||
cmds:
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp "{{.BINARY_PATH}}" "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
|
||||
- chmod +x "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
|
||||
- rm -f "{{.ARCHIVE_PATH}}"
|
||||
- env LC_ALL=C tar -C "{{.STAGING_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.ARCHIVE_BINARY_NAME}}"
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
|
||||
cleanup:package:artifacts:
|
||||
summary: 清理 Linux 打包中间产物
|
||||
internal: true
|
||||
vars:
|
||||
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
|
||||
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
|
||||
cmds:
|
||||
- rm -f "{{.BINARY_PATH}}"
|
||||
|
||||
run:
|
||||
summary: 运行 Linux 可执行文件
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}'
|
||||
preconditions:
|
||||
- sh: '[ "{{OS}}" = "linux" ]'
|
||||
msg: "Linux 运行任务仅支持在 Linux 主机上执行。"
|
||||
cmds:
|
||||
- '{{.OUTPUT}}'
|
||||
@@ -1,13 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Cursor助手
|
||||
Comment=Cursor助手
|
||||
# The Exec line includes %u to pass the URL to the application
|
||||
Exec=/usr/local/bin/Cursor助手 %u
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=Cursor助手
|
||||
Categories=Utility;
|
||||
StartupWMClass=Cursor助手
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Feel free to remove those if you don't want/need to use them.
|
||||
# Make sure to check the documentation at https://nfpm.goreleaser.com
|
||||
#
|
||||
# The lines below are called `modelines`. See `:help modeline`
|
||||
|
||||
name: "Cursor助手"
|
||||
arch: ${GOARCH}
|
||||
platform: "linux"
|
||||
version: "0.0.46"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
description: "Cursor助手"
|
||||
vendor: "Cursor助手"
|
||||
homepage: "https://wails.io"
|
||||
license: "MIT"
|
||||
release: "1"
|
||||
|
||||
contents:
|
||||
- src: "./bin/Cursor助手"
|
||||
dst: "/usr/local/bin/Cursor助手"
|
||||
- src: "./build/appicon.png"
|
||||
dst: "/usr/share/icons/hicolor/128x128/apps/Cursor助手.png"
|
||||
- src: "./build/linux/Cursor助手.desktop"
|
||||
dst: "/usr/share/applications/Cursor助手.desktop"
|
||||
|
||||
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
|
||||
# Distribution-specific overrides for different package formats and WebKit versions
|
||||
overrides:
|
||||
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||
rpm:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk4.1
|
||||
|
||||
# Arch Linux packages (WebKit 4.1)
|
||||
archlinux:
|
||||
depends:
|
||||
- gtk3
|
||||
- webkit2gtk-4.1
|
||||
|
||||
# scripts section to ensure desktop database is updated after install
|
||||
scripts:
|
||||
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
|
||||
# You can also add preremove, postremove if needed
|
||||
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
|
||||
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
|
||||
|
||||
# replaces:
|
||||
# - foobar
|
||||
# provides:
|
||||
# - bar
|
||||
# depends:
|
||||
# - gtk3
|
||||
# - libwebkit2gtk
|
||||
# recommends:
|
||||
# - whatever
|
||||
# suggests:
|
||||
# - something-else
|
||||
# conflicts:
|
||||
# - not-foo
|
||||
# - not-bar
|
||||
# changelog: "changelog.yaml"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,156 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: 构建 Windows 程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: build:native
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
|
||||
build:native:
|
||||
summary: 使用 Go 原生交叉编译构建 Windows 程序
|
||||
internal: true
|
||||
deps:
|
||||
- task: common:go:mod:tidy
|
||||
- task: common:build:frontend
|
||||
vars:
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- task: common:generate:icons
|
||||
cmds:
|
||||
- task: generate:syso
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
|
||||
- cmd: powershell Remove-item *.syso
|
||||
platforms: [windows]
|
||||
- cmd: rm -f *.syso
|
||||
platforms: [darwin, linux]
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
|
||||
env:
|
||||
GOOS: windows
|
||||
CGO_ENABLED: 0
|
||||
GOARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
package:
|
||||
summary: 打包 Windows 安装程序
|
||||
internal: true
|
||||
cmds:
|
||||
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
|
||||
vars:
|
||||
FORMAT: '{{.FORMAT | default "nsis"}}'
|
||||
|
||||
generate:syso:
|
||||
summary: 生成 Windows 图标与版本信息资源
|
||||
internal: true
|
||||
dir: build
|
||||
cmds:
|
||||
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
|
||||
create:nsis:installer:
|
||||
summary: 生成 NSIS 安装包
|
||||
internal: true
|
||||
dir: build/windows/nsis
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
cmds:
|
||||
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
|
||||
- |
|
||||
{{if eq OS "windows"}}
|
||||
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
|
||||
{{else}}
|
||||
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
|
||||
{{end}}
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
|
||||
|
||||
create:msix:package:
|
||||
summary: 生成 MSIX 安装包
|
||||
internal: true
|
||||
deps:
|
||||
- task: build
|
||||
vars:
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
cmds:
|
||||
- |-
|
||||
wails3 tool msix \
|
||||
--config "{{.ROOT_DIR}}/wails.json" \
|
||||
--name "{{.APP_NAME}}" \
|
||||
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
|
||||
--arch "{{.ARCH}}" \
|
||||
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
|
||||
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
|
||||
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
|
||||
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
CERT_PATH: '{{.CERT_PATH | default ""}}'
|
||||
PUBLISHER: '{{.PUBLISHER | default ""}}'
|
||||
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
|
||||
|
||||
create:zip:
|
||||
summary: 生成 Windows ZIP 包(包含 exe 与 certs)
|
||||
internal: true
|
||||
vars:
|
||||
ARCH: '{{.ARCH | default ARCH}}'
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}'
|
||||
STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}'
|
||||
cmds:
|
||||
- task: build
|
||||
vars:
|
||||
ARCH:
|
||||
ref: .ARCH
|
||||
OUTPUT:
|
||||
ref: .OUTPUT
|
||||
BUILD_FLAGS:
|
||||
ref: .BUILD_FLAGS
|
||||
EXTRA_TAGS:
|
||||
ref: .EXTRA_TAGS
|
||||
DEV:
|
||||
ref: .DEV
|
||||
SCAN:
|
||||
ref: .SCAN
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- mkdir -p "{{.STAGING_DIR}}"
|
||||
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
|
||||
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
|
||||
- rm -rf "{{.STAGING_DIR}}"
|
||||
- rm -f "{{.OUTPUT}}"
|
||||
|
||||
run:
|
||||
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
|
||||
vars:
|
||||
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
|
||||
cmds:
|
||||
- '{{.OUTPUT}}'
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.46"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.46",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
"ProductName": "Cursor助手",
|
||||
"Comments": "Cursor助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||
IgnorableNamespaces="uap3">
|
||||
|
||||
<Identity
|
||||
Name="com.cursor.wuxianxubei"
|
||||
Publisher="CN=Cursor助手"
|
||||
Version="0.1.0.0"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Cursor助手</DisplayName>
|
||||
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
|
||||
<Description>Cursor助手</Description>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
<Resource Language="zh-cn" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application Id="com.cursor.wuxianxubei" Executable="Cursor助手" EntryPoint="Windows.FullTrustApplication">
|
||||
<uap:VisualElements
|
||||
DisplayName="Cursor助手"
|
||||
Description="本地 MITM 代理客户端,支持中转转发与托盘控制"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
</uap:VisualElements>
|
||||
|
||||
<Extensions>
|
||||
<desktop:Extension Category="windows.fullTrustProcess" Executable="Cursor助手" />
|
||||
|
||||
|
||||
</Extensions>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
|
||||
</Capabilities>
|
||||
</Package>
|
||||
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MsixPackagingToolTemplate
|
||||
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
|
||||
<Settings
|
||||
AllowTelemetry="false"
|
||||
ApplyACLsToPackageFiles="true"
|
||||
GenerateCommandLineFile="true"
|
||||
AllowPromptForPassword="false">
|
||||
</Settings>
|
||||
<Installer
|
||||
Path="Cursor助手"
|
||||
Arguments=""
|
||||
InstallLocation="C:\Program Files\Cursor助手\Cursor助手">
|
||||
</Installer>
|
||||
<PackageInformation
|
||||
PackageName="Cursor助手"
|
||||
PackageDisplayName="Cursor助手"
|
||||
PublisherName="CN=Cursor助手"
|
||||
PublisherDisplayName="Cursor助手"
|
||||
Version="0.1.0.0"
|
||||
PackageDescription="Cursor助手">
|
||||
<Capabilities>
|
||||
<Capability Name="runFullTrust" />
|
||||
|
||||
</Capabilities>
|
||||
<Applications>
|
||||
<Application
|
||||
Id="com.cursor.wuxianxubei"
|
||||
Description="Cursor助手"
|
||||
DisplayName="Cursor助手"
|
||||
ExecutableName="Cursor助手"
|
||||
EntryPoint="Windows.FullTrustApplication">
|
||||
|
||||
</Application>
|
||||
</Applications>
|
||||
<Resources>
|
||||
<Resource Language="en-us" />
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||
</Dependencies>
|
||||
<Properties>
|
||||
<Framework>false</Framework>
|
||||
<DisplayName>Cursor助手</DisplayName>
|
||||
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
|
||||
<Description>Cursor助手</Description>
|
||||
<Logo>Assets\AppIcon.png</Logo>
|
||||
</Properties>
|
||||
</PackageInformation>
|
||||
<SaveLocation PackagePath="Cursor助手.msix" />
|
||||
<PackageIntegrity>
|
||||
<CertificatePath></CertificatePath>
|
||||
</PackageIntegrity>
|
||||
</MsixPackagingToolTemplate>
|
||||
Binary file not shown.
@@ -1,116 +0,0 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "my-project" # Default "cursorclient"
|
||||
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
|
||||
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
|
||||
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -1,236 +0,0 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.46"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
|
||||
!macroend
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
proxydebugger "cursor/cursor-proxy-debugger"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := proxydebugger.Config{}
|
||||
openBrowser := true
|
||||
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
|
||||
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
|
||||
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
|
||||
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||
flag.Parse()
|
||||
|
||||
server, err := proxydebugger.New(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Cursor 协议调试代理已启动\n")
|
||||
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
|
||||
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||
if openBrowser {
|
||||
_ = browser.OpenURL(server.UIURL())
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signals
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Close(shutdownContext); err != nil {
|
||||
log.Printf("关闭调试代理失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cursor BYOK</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cursor-byok-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CallDetail, LlmCall, Provider, ProviderInput, ProviderModel } from './types'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }))
|
||||
throw new Error(error.message ?? `HTTP ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
providers: () => request<Provider[]>('/api/providers'),
|
||||
createProvider: (input: ProviderInput) =>
|
||||
request<Provider>('/api/providers', { method: 'POST', body: JSON.stringify(input) }),
|
||||
updateProvider: (id: number, input: ProviderInput) =>
|
||||
request<Provider>(`/api/providers/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: number) => request<void>(`/api/providers/${id}`, { method: 'DELETE' }),
|
||||
discoverModels: (id: number) =>
|
||||
request<{ models: string[] }>(`/api/providers/${id}/models/discover`, { method: 'POST' }),
|
||||
saveModels: (id: number, models: unknown[]) =>
|
||||
request<ProviderModel[]>(`/api/providers/${id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ models }),
|
||||
}),
|
||||
models: () => request<ProviderModel[]>('/api/models'),
|
||||
deleteModel: (hash: string) => request<void>(`/api/models/${hash}`, { method: 'DELETE' }),
|
||||
calls: () => request<LlmCall[]>('/api/llm-calls'),
|
||||
call: (id: string) => request<CallDetail>(`/api/llm-calls/${encodeURIComponent(id)}`),
|
||||
observability: () => request<{ detailed: boolean }>('/api/settings/observability'),
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>('/api/settings/observability', {
|
||||
method: 'PUT', body: JSON.stringify({ detailed }),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export type ProviderType = 'openai-chat' | 'openai-responses' | 'anthropic'
|
||||
|
||||
export interface Provider {
|
||||
provider_id: number
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
has_api_key: boolean
|
||||
custom_headers: Record<string, string | null>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
api_key?: string
|
||||
custom_headers: Record<string, string | null>
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
model_hash: string
|
||||
provider_id: number
|
||||
model_id: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
sort_order: number
|
||||
context_window_tokens?: number
|
||||
max_output_tokens?: number
|
||||
reasoning_enabled: boolean
|
||||
reasoning_effort?: string
|
||||
extra_params: Record<string, unknown>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface LlmCall {
|
||||
call_id: string
|
||||
run_id: string
|
||||
conversation_id: string
|
||||
model_hash?: string
|
||||
model_id: string
|
||||
display_name: string
|
||||
provider_type: ProviderType
|
||||
status: string
|
||||
created_at_ms: number
|
||||
duration_ms?: number
|
||||
ttfb_ms?: number
|
||||
ttft_ms?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
detailed: boolean
|
||||
}
|
||||
|
||||
export interface CallDetail {
|
||||
call: LlmCall
|
||||
request?: { headers: Record<string, string>; body: unknown; byte_count: number }
|
||||
response_chunks: { seq: number; received_offset_ms: number; data: string; byte_count: number }[]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NavLink, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { CallDetailPage } from '../features/calls/CallDetailPage'
|
||||
import { CallsPage } from '../features/calls/CallsPage'
|
||||
import { ModelsPage } from '../features/models/ModelsPage'
|
||||
import { ProvidersPage } from '../features/providers/ProvidersPage'
|
||||
import { ObservabilityPage } from '../features/settings/ObservabilityPage'
|
||||
|
||||
const links = [
|
||||
['/', 'Providers'],
|
||||
['/models', 'Models'],
|
||||
['/calls', 'LLM Calls'],
|
||||
['/settings', 'Settings'],
|
||||
] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="border-b border-zinc-800 bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-8 px-6 py-4">
|
||||
<div className="text-lg font-semibold">Cursor BYOK</div>
|
||||
<nav className="flex gap-2">
|
||||
{links.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm ${isActive ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white'}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProvidersPage />} />
|
||||
<Route path="/models" element={<ModelsPage />} />
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:callId" element={<CallDetailPage />} />
|
||||
<Route path="/settings" element={<ObservabilityPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function CallDetailPage() {
|
||||
const { callId = '' } = useParams()
|
||||
const detail = useQuery({ queryKey: ['call', callId], queryFn: () => api.call(callId) })
|
||||
if (!detail.data) return <p>加载中…</p>
|
||||
const { call, request, response_chunks: chunks } = detail.data
|
||||
return <section className="grid gap-6">
|
||||
<div><h1>{call.display_name}</h1><p><code>{call.call_id}</code> · {call.status}</p></div>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<Metric label="TTFB" value={call.ttfb_ms} suffix="ms" /><Metric label="TTFT" value={call.ttft_ms} suffix="ms" />
|
||||
<Metric label="Duration" value={call.duration_ms} suffix="ms" /><Metric label="Total tokens" value={call.total_tokens} />
|
||||
</div>
|
||||
<Payload title="Request" value={request ?? '详细模式未记录'} />
|
||||
<Payload title="Response stream" value={chunks.length ? chunks : '详细模式未记录'} />
|
||||
</section>
|
||||
}
|
||||
|
||||
function Metric({ label, value, suffix = '' }: { label: string; value?: number; suffix?: string }) {
|
||||
return <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"><p>{label}</p><div className="mt-2 text-xl">{value ?? '—'} {value == null ? '' : suffix}</div></div>
|
||||
}
|
||||
|
||||
function Payload({ title, value }: { title: string; value: unknown }) {
|
||||
return <div><h2>{title}</h2><pre className="mt-2 max-h-[32rem] overflow-auto rounded-xl border border-zinc-800 bg-black p-4 text-xs text-zinc-300">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function CallsPage() {
|
||||
const calls = useQuery({ queryKey: ['calls'], queryFn: api.calls, refetchInterval: 3000 })
|
||||
return <section><div><h1>LLM Calls</h1><p>每一行对应一次真实 Provider 请求。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>时间</th><th>模型</th><th>状态</th><th>TTFT</th><th>耗时</th><th>Tokens</th></tr></thead>
|
||||
<tbody>{calls.data?.map((call) => <tr key={call.call_id}>
|
||||
<td><Link className="text-blue-400 hover:underline" to={`/calls/${encodeURIComponent(call.call_id)}`}>{new Date(call.created_at_ms).toLocaleString()}</Link></td><td>{call.display_name}<small>{call.model_id}</small></td><td>{call.status}</td>
|
||||
<td>{call.ttft_ms == null ? '—' : `${call.ttft_ms} ms`}</td><td>{call.duration_ms == null ? '—' : `${call.duration_ms} ms`}</td><td>{call.total_tokens ?? '—'}</td>
|
||||
</tr>)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import type { ProviderModel } from '../../api/types'
|
||||
|
||||
export function ModelsPage() {
|
||||
const client = useQueryClient()
|
||||
const models = useQuery({ queryKey: ['models'], queryFn: api.models })
|
||||
const save = useMutation({
|
||||
mutationFn: ({ model, input }: { model: ProviderModel; input: ModelEdit }) =>
|
||||
api.saveModels(model.provider_id, [{
|
||||
model_id: model.model_id, display_name: input.displayName, enabled: input.enabled, sort_order: model.sort_order,
|
||||
context_window_tokens: input.contextWindow || undefined, max_output_tokens: input.maxOutput || undefined,
|
||||
reasoning_enabled: input.reasoning, reasoning_effort: input.effort || undefined,
|
||||
extra_params: model.extra_params,
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
return <section><div><h1>Models</h1><p>Hash 是 Cursor 和其他客户端使用的稳定公开标识。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>Hash / Provider ID</th><th>Display name</th><th>Context</th><th>Max output</th><th>Reasoning</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>{models.data?.map((model) => <ModelRow key={model.model_hash} model={model} onSave={(input) => save.mutate({ model, input })} />)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
interface ModelEdit { displayName: string; enabled: boolean; contextWindow: number; maxOutput: number; reasoning: boolean; effort: string }
|
||||
|
||||
function ModelRow({ model, onSave }: { model: ProviderModel; onSave: (input: ModelEdit) => void }) {
|
||||
const [displayName, setDisplayName] = useState(model.display_name)
|
||||
const [contextWindow, setContextWindow] = useState(model.context_window_tokens ?? 0)
|
||||
const [maxOutput, setMaxOutput] = useState(model.max_output_tokens ?? 0)
|
||||
const [reasoning, setReasoning] = useState(model.reasoning_enabled)
|
||||
const [effort, setEffort] = useState(model.reasoning_effort ?? '')
|
||||
const value = (enabled: boolean): ModelEdit => ({ displayName, enabled, contextWindow, maxOutput, reasoning, effort })
|
||||
return <tr>
|
||||
<td><code>{model.model_hash}</code><small>{model.model_id}</small></td>
|
||||
<td><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></td>
|
||||
<td><input type="number" value={contextWindow || ''} onChange={(event) => setContextWindow(Number(event.target.value))} /></td>
|
||||
<td><input type="number" value={maxOutput || ''} onChange={(event) => setMaxOutput(Number(event.target.value))} /></td>
|
||||
<td><div className="flex items-center gap-2"><input className="h-4 w-4" type="checkbox" checked={reasoning} onChange={(event) => setReasoning(event.target.checked)} /><input placeholder="effort" value={effort} onChange={(event) => setEffort(event.target.value)} /></div></td>
|
||||
<td>{model.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
<td><div className="flex gap-2"><button onClick={() => onSave(value(model.enabled))}>保存</button><button onClick={() => onSave(value(!model.enabled))}>{model.enabled ? '停用' : '启用'}</button></div></td>
|
||||
</tr>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { Provider, ProviderInput, ProviderType } from '../../api/types'
|
||||
|
||||
export function ProviderForm({ provider, onSave, busy }: { provider?: Provider; onSave: (value: ProviderInput) => void; busy: boolean }) {
|
||||
const [name, setName] = useState(provider?.name ?? '')
|
||||
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type ?? 'openai-chat')
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.base_url ?? 'https://api.openai.com/v1')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [headers, setHeaders] = useState(JSON.stringify(provider?.custom_headers ?? {}, null, 2))
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 md:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSave({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey || undefined, custom_headers: JSON.parse(headers) })
|
||||
}}
|
||||
>
|
||||
<Field label="名称"><input value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
<Field label="类型">
|
||||
<select value={providerType} onChange={(e) => setProviderType(e.target.value as ProviderType)}>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Base URL"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} required /></Field>
|
||||
<Field label="API Key"><input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></Field>
|
||||
<Field label="Custom headers (JSON)"><textarea value={headers} onChange={(e) => setHeaders(e.target.value)} /></Field>
|
||||
<div className="md:col-span-2"><button disabled={busy}>{busy ? '保存中…' : provider ? '保存 Provider' : '添加 Provider'}</button></div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: React.PropsWithChildren<{ label: string }>) {
|
||||
return <label className="grid gap-2 text-sm text-zinc-400"><span>{label}</span>{children}</label>
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { ProviderForm } from './ProviderForm'
|
||||
import type { Provider } from '../../api/types'
|
||||
|
||||
export function ProvidersPage() {
|
||||
const client = useQueryClient()
|
||||
const providers = useQuery({ queryKey: ['providers'], queryFn: api.providers })
|
||||
const [discoveries, setDiscoveries] = useState<Record<number, string[]>>({})
|
||||
const [editing, setEditing] = useState<Provider>()
|
||||
const create = useMutation({
|
||||
mutationFn: api.createProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, value }: { id: number; value: Parameters<typeof api.updateProvider>[1] }) => api.updateProvider(id, value),
|
||||
onSuccess: () => { setEditing(undefined); client.invalidateQueries({ queryKey: ['providers'] }) },
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.deleteProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const discover = useMutation({
|
||||
mutationFn: api.discoverModels,
|
||||
onSuccess: (result, id) => setDiscoveries((current) => ({ ...current, [id]: result.models })),
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: ({ id, model }: { id: number; model: string }) => api.saveModels(id, [{
|
||||
model_id: model, display_name: model, enabled: true, sort_order: 0,
|
||||
reasoning_enabled: false, extra_params: {},
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="grid gap-8">
|
||||
<div><h1>Provider</h1><p>配置端点并从 Provider 拉取可用模型。</p></div>
|
||||
<ProviderForm key={editing?.provider_id ?? 'new'} provider={editing} onSave={(value) => editing
|
||||
? update.mutate({ id: editing.provider_id, value })
|
||||
: create.mutate(value)} busy={create.isPending || update.isPending} />
|
||||
<div className="grid gap-4">
|
||||
{providers.data?.map((provider) => (
|
||||
<article key={provider.provider_id} className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div><h2>{provider.name}</h2><p>{provider.provider_type} · {provider.base_url}</p></div>
|
||||
<div className="flex gap-2"><button onClick={() => setEditing(provider)}>编辑</button><button onClick={() => discover.mutate(provider.provider_id)}>拉取模型</button><button className="danger" onClick={() => remove.mutate(provider.provider_id)}>删除</button></div>
|
||||
</div>
|
||||
{discoveries[provider.provider_id] && (
|
||||
<div className="mt-4 grid gap-2 border-t border-zinc-800 pt-4">
|
||||
{discoveries[provider.provider_id].map((model) => (
|
||||
<div key={model} className="flex items-center justify-between rounded-md bg-zinc-950 px-3 py-2 text-sm">
|
||||
<code>{model}</code>
|
||||
<button onClick={() => save.mutate({ id: provider.provider_id, model })}>添加</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const client = useQueryClient()
|
||||
const settings = useQuery({ queryKey: ['observability'], queryFn: api.observability })
|
||||
const update = useMutation({ mutationFn: api.setObservability, onSuccess: () => client.invalidateQueries({ queryKey: ['observability'] }) })
|
||||
return <section><h1>Observability</h1><p>概要始终保存;详细模式额外保存脱敏请求和流响应。</p>
|
||||
<label className="mt-6 flex max-w-xl items-center justify-between rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<span><strong>详细记录</strong><p>仅影响开启后的新调用。</p></span>
|
||||
<input className="h-5 w-5" type="checkbox" checked={settings.data?.detailed ?? false} onChange={(event) => update.mutate(event.target.checked)} />
|
||||
</label>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import { App } from './app/App'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body { @apply m-0 bg-zinc-950 font-sans text-zinc-100 antialiased; }
|
||||
h1 { @apply text-2xl font-semibold tracking-tight; }
|
||||
h2 { @apply text-base font-semibold; }
|
||||
p { @apply mt-1 text-sm text-zinc-400; }
|
||||
input, select, textarea { @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-zinc-100 outline-none focus:border-blue-500; }
|
||||
textarea { @apply min-h-24 font-mono text-xs; }
|
||||
button { @apply rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50; }
|
||||
table { @apply w-full border-collapse bg-zinc-900 text-left text-sm; }
|
||||
th { @apply bg-zinc-950 px-4 py-3 font-medium text-zinc-400; }
|
||||
td { @apply border-t border-zinc-800 px-4 py-3; }
|
||||
td small { @apply block text-zinc-500; }
|
||||
code { @apply font-mono text-xs text-blue-300; }
|
||||
button.danger { @apply bg-red-950 text-red-300 hover:bg-red-900; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/console/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': 'http://127.0.0.1:3000' },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
# Cursor Protocol Debugger
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
This standalone local Cursor API debugging service forwards every HTTP request outside the `__debuger__` debugging namespace to the fixed upstream `https://api2.cursor.sh`, preserving the method, path, query, headers, and body. It continues to capture `BidiAppend`, `RunSSE`, Fork Chat, and model-discovery traffic.
|
||||
|
||||
It is not a general-purpose HTTP proxy, does not handle `CONNECT`, requires no CA certificate, and does not modify the system proxy.
|
||||
|
||||
## Start
|
||||
|
||||
Generate the sibling `cursor-proto` module's Go code before the first build:
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go run .
|
||||
```
|
||||
|
||||
The service listens on a single port:
|
||||
|
||||
- Cursor API service: `http://127.0.0.1:9090`
|
||||
- Debugging UI: `http://127.0.0.1:9090/__debuger__/`
|
||||
- Debugging API: `http://127.0.0.1:9090/__debuger__/api/*`
|
||||
- Fixed upstream: `https://api2.cursor.sh`
|
||||
|
||||
The debugging UI opens automatically after startup.
|
||||
|
||||
## Configure Cursor
|
||||
|
||||
Quit Cursor completely, then launch it from a terminal with the local API address:
|
||||
|
||||
```bash
|
||||
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
|
||||
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
|
||||
/Applications/Cursor.app/Contents/MacOS/Cursor
|
||||
```
|
||||
|
||||
`CURSOR_API_ENDPOINT` overrides the Agent API endpoint. `CURSOR_API_BASE_URL` also routes requests such as authentication that use the base API address through this service. Cursor proxy and Network settings do not need to be changed.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go build -o ./bin/cursor-proxy-debugger .
|
||||
```
|
||||
|
||||
## Dependency Layout
|
||||
|
||||
The debugger is an independent Go module. Cursor protobuf message packages are generated by the sibling `cursor-proto` module. The generated `gen/` directory is not committed, so run its `scripts/generate.sh` before the first build. This project does not depend on the outer `cursor-byok` Go module.
|
||||
|
||||
## Options
|
||||
|
||||
```text
|
||||
-addr Cursor API service listen address; default: 127.0.0.1:9090
|
||||
-max-exchanges Maximum exchanges retained in memory; default: 200
|
||||
-db SQLite database path; defaults to the user configuration directory
|
||||
-open Open the browser after startup; default: true
|
||||
```
|
||||
|
||||
## Data Handling
|
||||
|
||||
- Every request received by the service is forwarded to `https://api2.cursor.sh`; clients cannot select another upstream.
|
||||
- The `__debuger__` namespace is reserved for the local debugging page and API and is never forwarded upstream.
|
||||
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
||||
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
||||
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
|
||||
- `CppService/AvailableModels`, `AiService/AvailableModels`, `GetDefaultModel`, and `GetDefaultModelNudgeData` are decoded bidirectionally.
|
||||
- Requests can be filtered by time and protocol `request_id`; the UI can query by `conversation_id` and group requests by conversation.
|
||||
- Complete captures are stored in SQLite and remain queryable after restart; `max-exchanges` only limits hot in-memory data.
|
||||
- Sensitive headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
|
||||
- Raw bodies are retained up to 2 MiB per side by default; capture limits never truncate forwarded traffic.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Cursor 协议调试器
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
这是一个独立运行的本地 Cursor API 调试服务。除 `__debuger__` 调试命名空间外,进入服务端口的 HTTP 请求都会保留方法、路径、查询参数、请求头和请求体,并转发到固定上游 `https://api2.cursor.sh`。服务同时记录 `BidiAppend`、`RunSSE`、Fork Chat 和模型发现等流量。
|
||||
|
||||
它不是通用 HTTP 代理,不处理 `CONNECT`,不需要 CA 证书,也不会修改系统代理。
|
||||
|
||||
## 启动
|
||||
|
||||
首次构建前先生成相邻 `cursor-proto` 项目的 Go 代码:
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go run .
|
||||
```
|
||||
|
||||
服务只监听一个端口:
|
||||
|
||||
- Cursor API 服务:`http://127.0.0.1:9090`
|
||||
- 调试界面:`http://127.0.0.1:9090/__debuger__/`
|
||||
- 调试 API:`http://127.0.0.1:9090/__debuger__/api/*`
|
||||
- 固定上游:`https://api2.cursor.sh`
|
||||
|
||||
启动后会自动打开调试界面。
|
||||
|
||||
## 配置 Cursor
|
||||
|
||||
完全退出 Cursor 后,从终端指定本地 API 地址启动:
|
||||
|
||||
```bash
|
||||
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
|
||||
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
|
||||
/Applications/Cursor.app/Contents/MacOS/Cursor
|
||||
```
|
||||
|
||||
`CURSOR_API_ENDPOINT` 覆盖 Agent API 地址;`CURSOR_API_BASE_URL` 让使用基础 API 地址的认证等请求也经过本服务。无需修改 Cursor 代理设置或 Network 设置。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go build -o ./bin/cursor-proxy-debugger .
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
调试器是独立 Go module。Cursor protobuf 消息包由相邻的 `cursor-proto` module 生成;生成的 `gen/` 目录不提交到 Git,因此首次构建前需要运行其 `scripts/generate.sh`。本项目不依赖外层 `cursor-byok` Go module。
|
||||
|
||||
## 参数
|
||||
|
||||
```text
|
||||
-addr Cursor API 服务监听地址,默认 127.0.0.1:9090
|
||||
-max-exchanges 内存中保留的最大请求数,默认 200
|
||||
-db SQLite 数据库路径,默认位于用户配置目录
|
||||
-open 启动后是否打开浏览器,默认 true
|
||||
```
|
||||
|
||||
## 数据处理
|
||||
|
||||
- 所有服务端口收到的请求都固定转发到 `https://api2.cursor.sh`,不会接受客户端指定的其他上游。
|
||||
- `__debuger__` 命名空间由本地调试页面和调试 API 保留,不会转发到上游。
|
||||
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||
- Fork Chat 的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。
|
||||
- `CppService/AvailableModels`、`AiService/AvailableModels`、`GetDefaultModel` 和 `GetDefaultModelNudgeData` 会双向解码模型相关数据。
|
||||
- 请求列表支持按时间和协议 `request_id` 过滤;调试界面可按 `conversation_id` 查询并按会话分组。
|
||||
- 完整抓包写入 SQLite,重启后仍可查询;`max-exchanges` 只限制内存热数据数量。
|
||||
- `Authorization`、`Cookie`、`Set-Cookie` 等敏感请求头在界面中默认隐藏。
|
||||
- 单侧原始正文默认最多保留 2 MiB;转发内容不会被抓取上限截断。
|
||||
@@ -1,4 +1,5 @@
|
||||
package proxydebugger
|
||||
// capture.go 在不影响上游转发的前提下截取有限大小的 HTTP 流。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -7,6 +8,7 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// captureReadCloser 包装响应体并并发安全地累计诊断副本。
|
||||
type captureReadCloser struct {
|
||||
source io.ReadCloser
|
||||
mu sync.Mutex
|
||||
@@ -19,6 +21,7 @@ type captureReadCloser struct {
|
||||
onDone func(captured []byte, size int64, truncated bool, readErr error)
|
||||
}
|
||||
|
||||
// newCaptureReadCloser 创建带分块和完成回调的捕获读取器。
|
||||
func newCaptureReadCloser(
|
||||
source io.ReadCloser,
|
||||
limit int,
|
||||
@@ -33,6 +36,7 @@ func newCaptureReadCloser(
|
||||
}
|
||||
}
|
||||
|
||||
// Read 转发读取结果并保存不超过限制的副本。
|
||||
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
|
||||
read, err := reader.source.Read(payload)
|
||||
if read > 0 {
|
||||
@@ -61,12 +65,14 @@ func (reader *captureReadCloser) Read(payload []byte) (int, error) {
|
||||
return read, err
|
||||
}
|
||||
|
||||
// Close 关闭原始响应体并保证完成回调只执行一次。
|
||||
func (reader *captureReadCloser) Close() error {
|
||||
err := reader.source.Close()
|
||||
reader.finish(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// finish 固化捕获快照并在锁外调用完成回调。
|
||||
func (reader *captureReadCloser) finish(readErr error) {
|
||||
reader.mu.Lock()
|
||||
if reader.done {
|
||||
@@ -83,6 +89,7 @@ func (reader *captureReadCloser) finish(readErr error) {
|
||||
}
|
||||
}
|
||||
|
||||
// rawHex 把捕获字节编码为便于 JSON 持久化的十六进制文本。
|
||||
func rawHex(payload []byte) string {
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// capture_pipeline.go 负责服务请求响应体的捕获、解码和事件追加。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// exchangeIDContextKey 隔离反向服务内部使用的捕获编号。
|
||||
type exchangeIDContextKey struct{}
|
||||
|
||||
// captureRequest 捕获请求元数据并安装请求体读取器。
|
||||
func (server *Server) captureRequest(request *http.Request) *http.Request {
|
||||
if request == nil {
|
||||
return request
|
||||
}
|
||||
server.captureMu.RLock()
|
||||
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||
path := request.URL.Path
|
||||
upstreamURL := *request.URL
|
||||
upstreamURL.Scheme = server.upstream.Scheme
|
||||
upstreamURL.Host = server.upstream.Host
|
||||
upstreamURL.User = nil
|
||||
requestContentType := request.Header.Get("Content-Type")
|
||||
requestCodec := requestContentCodec(path, request.Header)
|
||||
exchange := &Exchange{
|
||||
ExchangeSummary: ExchangeSummary{
|
||||
ID: id,
|
||||
StartedAt: time.Now(),
|
||||
Method: request.Method,
|
||||
URL: upstreamURL.String(),
|
||||
Host: server.upstream.Host,
|
||||
Path: path,
|
||||
State: "pending",
|
||||
},
|
||||
Request: Payload{
|
||||
Headers: sortedHeaders(request.Header),
|
||||
ContentType: requestContentType,
|
||||
ContentCodec: requestCodec,
|
||||
Frames: make([]FrameView, 0),
|
||||
},
|
||||
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||
}
|
||||
server.store.create(exchange)
|
||||
server.captureMu.RUnlock()
|
||||
request = request.WithContext(context.WithValue(request.Context(), exchangeIDContextKey{}, id))
|
||||
request.Close = false
|
||||
|
||||
if request.Body == nil {
|
||||
server.finishRequestBody(id, path, requestContentType, requestCodec, nil, 0, false, nil)
|
||||
return request
|
||||
}
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if messageType := streamingRequestMessageType(path); messageType != "" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
messageType,
|
||||
requestCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendRequestFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
request.Body = newCaptureReadCloser(
|
||||
request.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishRequestBody(id, path, requestContentType, requestCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
// clearExchanges 清空内存和持久化捕获,并重置递增编号。
|
||||
func (server *Server) clearExchanges() error {
|
||||
server.captureMu.Lock()
|
||||
defer server.captureMu.Unlock()
|
||||
if err := server.store.clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
server.counter.Store(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureResponse 创建响应记录更新并包装响应体捕获器。
|
||||
func (server *Server) captureResponse(response *http.Response) error {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
id := exchangeID(response.Request)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
path := ""
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
path = response.Request.URL.Path
|
||||
}
|
||||
responseCodec := responseContentCodec(path, response.Header)
|
||||
responseContentType := response.Header.Get("Content-Type")
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.Status = response.StatusCode
|
||||
exchange.State = "streaming"
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||
exchange.Response.ContentType = responseContentType
|
||||
exchange.Response.ContentCodec = responseCodec
|
||||
})
|
||||
if response.Body == nil {
|
||||
server.finishResponseBody(id, path, responseContentType, responseCodec, nil, 0, false, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if messageType := streamingResponseMessageType(path); messageType != "" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
messageType,
|
||||
responseCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendResponseFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
response.Body = newCaptureReadCloser(
|
||||
response.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishResponseBody(id, path, responseContentType, responseCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// failExchange 保存反向转发失败状态。
|
||||
func (server *Server) failExchange(request *http.Request, upstreamErr error) {
|
||||
id := exchangeID(request)
|
||||
if id == "" || upstreamErr == nil {
|
||||
return
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = upstreamErr.Error()
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// finishRequestBody 解压、解码并保存完整请求体的最终状态。
|
||||
func (server *Server) finishRequestBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
decodeProto := decodesUnaryRequest(path) && isUnaryProtoContentType(contentType)
|
||||
if decodeProto && truncated {
|
||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, decodedLang, kind, requestID, conversationID, decodeErr := "", "", "", "", "", contentDecodeErr
|
||||
if decodeProto && decodeErr == nil {
|
||||
decodedJSON, kind, requestID, conversationID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||
}
|
||||
if decodeErr == nil && decodedJSON != "" {
|
||||
decodedLang = "json"
|
||||
} else if !decodeProto {
|
||||
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.RequestBytes = size
|
||||
exchange.Request.Size = size
|
||||
exchange.Request.RawHex = rawHex(captured)
|
||||
exchange.Request.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Request.DecodedJSON = decodedJSON
|
||||
exchange.Request.DecodedLang = decodedLang
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.RequestKind = kind
|
||||
}
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if conversationID != "" {
|
||||
exchange.ConversationID = conversationID
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Request.DecodeError = decodeErr.Error()
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// requestContentCodec 读取请求方向的 Connect 或 HTTP 压缩编码。
|
||||
func requestContentCodec(path string, headers http.Header) string {
|
||||
if streamingRequestMessageType(path) != "" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
// responseContentCodec 读取响应方向的 Connect 或 HTTP 压缩编码。
|
||||
func responseContentCodec(path string, headers http.Header) string {
|
||||
if streamingResponseMessageType(path) != "" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
if !decodesUnaryResponse(path) {
|
||||
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||
return codec
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
// finishResponseBody 解压、解码并保存完整响应体的最终状态。
|
||||
func (server *Server) finishResponseBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
decodeProto := decodesUnaryResponse(path) && isUnaryProtoContentType(contentType)
|
||||
if decodeProto && truncated {
|
||||
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, decodedLang, kind, decodeErr := "", "", "", contentDecodeErr
|
||||
if decodeProto && decodeErr == nil {
|
||||
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||
}
|
||||
if decodeErr == nil && decodedJSON != "" {
|
||||
decodedLang = "json"
|
||||
} else if !decodeProto {
|
||||
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.ResponseBytes = size
|
||||
exchange.Response.Size = size
|
||||
exchange.Response.RawHex = rawHex(captured)
|
||||
exchange.Response.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Response.DecodedJSON = decodedJSON
|
||||
exchange.Response.DecodedLang = decodedLang
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.ResponseKind = kind
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Response.DecodeError = decodeErr.Error()
|
||||
}
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.State = "completed"
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// appendRequestFrame 把请求方向的流式帧追加到临时快照。
|
||||
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||
server.store.updateTransient(id, func(exchange *Exchange) {
|
||||
if len(exchange.Request.Frames) < server.config.MaxFrames {
|
||||
exchange.Request.Frames = append(exchange.Request.Frames, frame)
|
||||
}
|
||||
if frame.Kind != "" {
|
||||
exchange.RequestKind = frame.Kind
|
||||
}
|
||||
if frame.RequestID != "" {
|
||||
exchange.RequestID = frame.RequestID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// appendResponseFrame 把响应方向的流式帧追加到临时快照。
|
||||
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||
server.store.updateTransient(id, func(exchange *Exchange) {
|
||||
if len(exchange.Response.Frames) < server.config.MaxFrames {
|
||||
exchange.Response.Frames = append(exchange.Response.Frames, frame)
|
||||
}
|
||||
exchange.FrameCount = len(exchange.Response.Frames)
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.ResponseKind = frame.Kind
|
||||
}
|
||||
if frame.Error != "" {
|
||||
exchange.Response.DecodeError = frame.Error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// exchangeID 从请求上下文读取捕获记录编号。
|
||||
func exchangeID(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := request.Context().Value(exchangeIDContextKey{}).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// browserAddress 把通配监听地址转换为浏览器可访问的回环地址。
|
||||
func browserAddress(address string) string {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return address
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// validateLoopbackAddress 拒绝把调试服务暴露到非回环网卡。
|
||||
func validateLoopbackAddress(address string) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("调试服务监听地址无效:%w", err)
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return errors.New("调试服务只能监听本机回环地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// decode.go 解析 Connect 帧、压缩载荷和 Cursor protobuf 消息视图。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
|
||||
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// maxConnectFrameBytes 防止异常帧长度导致调试器分配过大内存。
|
||||
const maxConnectFrameBytes = 64 << 20
|
||||
|
||||
// 协议路径常量用于选择精确的 protobuf 请求、响应和流式消息类型。
|
||||
const (
|
||||
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
||||
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
||||
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
||||
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
||||
cppAvailableModelsPath = "/aiserver.v1.CppService/AvailableModels"
|
||||
aiAvailableModelsPath = "/aiserver.v1.AiService/AvailableModels"
|
||||
aiGetDefaultModelPath = "/aiserver.v1.AiService/GetDefaultModel"
|
||||
aiDefaultModelNudgeDataPath = "/aiserver.v1.AiService/GetDefaultModelNudgeData"
|
||||
mcpGetKnownServersPath = "/aiserver.v1.MCPRegistryService/GetKnownServers"
|
||||
serverGetConfigPath = "/aiserver.v1.ServerConfigService/GetServerConfig"
|
||||
runSSEPath = "/agent.v1.AgentService/RunSSE"
|
||||
)
|
||||
|
||||
// connectFrameDecoder 在任意读取边界下累计并解析 Connect 五字节帧。
|
||||
type connectFrameDecoder struct {
|
||||
buffer []byte
|
||||
messageType string
|
||||
codec string
|
||||
maxFrames int
|
||||
frameCount int
|
||||
onFrame func(FrameView)
|
||||
}
|
||||
|
||||
// newConnectFrameDecoder 创建指定 protobuf 类型的流式解码器。
|
||||
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
|
||||
return &connectFrameDecoder{
|
||||
messageType: messageType,
|
||||
codec: strings.TrimSpace(codec),
|
||||
maxFrames: maxFrames,
|
||||
onFrame: onFrame,
|
||||
}
|
||||
}
|
||||
|
||||
// Write 追加任意长度的网络片段并尽可能产出完整帧。
|
||||
func (decoder *connectFrameDecoder) Write(payload []byte) {
|
||||
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
|
||||
return
|
||||
}
|
||||
decoder.buffer = append(decoder.buffer, payload...)
|
||||
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
|
||||
flags := decoder.buffer[0]
|
||||
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
|
||||
if length < 0 || length > maxConnectFrameBytes {
|
||||
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
|
||||
decoder.buffer = nil
|
||||
return
|
||||
}
|
||||
if len(decoder.buffer) < 5+length {
|
||||
return
|
||||
}
|
||||
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
|
||||
decoder.buffer = decoder.buffer[5+length:]
|
||||
decoder.emit(decoder.decode(flags, framePayload))
|
||||
}
|
||||
}
|
||||
|
||||
// Close 标记流结束并暴露尚未完整的尾部错误。
|
||||
func (decoder *connectFrameDecoder) Close() {
|
||||
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
|
||||
decoder.emit(FrameView{
|
||||
Length: len(decoder.buffer),
|
||||
RawHex: clippedHex(decoder.buffer, 4096),
|
||||
Error: "流结束时仍有不完整的 Connect 帧",
|
||||
})
|
||||
}
|
||||
decoder.buffer = nil
|
||||
}
|
||||
|
||||
// emit 在达到帧数上限前调用帧回调。
|
||||
func (decoder *connectFrameDecoder) emit(frame FrameView) {
|
||||
frame.Index = decoder.frameCount
|
||||
decoder.frameCount++
|
||||
if decoder.onFrame != nil {
|
||||
decoder.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// decode 解压并解析单条 Connect 帧。
|
||||
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
|
||||
frame := FrameView{
|
||||
Flags: flags,
|
||||
Length: len(payload),
|
||||
Compressed: flags&0x01 != 0,
|
||||
EndStream: flags&0x02 != 0,
|
||||
RawHex: clippedHex(payload, 4096),
|
||||
}
|
||||
decoded := payload
|
||||
if frame.Compressed {
|
||||
var err error
|
||||
decoded, err = decompressPayload(payload, decoder.codec)
|
||||
if err != nil {
|
||||
frame.Error = err.Error()
|
||||
return frame
|
||||
}
|
||||
}
|
||||
if frame.EndStream {
|
||||
frame.Kind = "end_stream"
|
||||
frame.MessageType = "connect.error.v1.EndStreamResponse"
|
||||
frame.JSON = prettyJSON(decoded)
|
||||
return frame
|
||||
}
|
||||
|
||||
message := newMessage(decoder.messageType)
|
||||
if message == nil {
|
||||
frame.Error = "未知的 protobuf 消息类型"
|
||||
return frame
|
||||
}
|
||||
if err := proto.Unmarshal(decoded, message); err != nil {
|
||||
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
|
||||
return frame
|
||||
}
|
||||
frame.MessageType = decoder.messageType
|
||||
frame.Kind = activeOneofName(message)
|
||||
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
|
||||
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
|
||||
}
|
||||
frame.JSON = marshalProtoJSON(message)
|
||||
return frame
|
||||
}
|
||||
|
||||
// decompressPayload 使用协议声明的编码解压载荷。
|
||||
func decompressPayload(payload []byte, codec string) ([]byte, error) {
|
||||
if codec != "" && !strings.EqualFold(codec, "gzip") {
|
||||
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip 解压失败:%w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
|
||||
}
|
||||
if len(decoded) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// decodeUnaryRequest 解析单次 RPC 请求并提取关键关联标识。
|
||||
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, conversationID string, err error) {
|
||||
switch path {
|
||||
case bidiAppendPath:
|
||||
request := &aiserverv1.BidiAppendRequest{}
|
||||
if err := proto.Unmarshal(payload, request); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
|
||||
outer := marshalProtoJSON(request)
|
||||
clientMessage, clientKind, decodeErr := decodeBidiClientMessage(request)
|
||||
if decodeErr != nil || clientMessage == nil {
|
||||
return outer, "bidi_append", requestID, "", decodeErr
|
||||
}
|
||||
combined := struct {
|
||||
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
|
||||
AgentClientKind string `json:"agent_client_kind"`
|
||||
AgentClient json.RawMessage `json:"agent_client_message"`
|
||||
}{
|
||||
BidiAppendRequest: json.RawMessage(outer),
|
||||
AgentClientKind: clientKind,
|
||||
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
|
||||
}
|
||||
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
|
||||
return string(formatted), clientKind, requestID, conversationIDFromClientMessage(clientMessage), marshalErr
|
||||
}
|
||||
message, kind := unaryRequestMessage(path)
|
||||
if message == nil {
|
||||
return "", "", "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, "", conversationIDFromUnaryRequest(message), nil
|
||||
}
|
||||
|
||||
// decodeBidiClientMessage 解析 BidiAppend 携带的十六进制 Agent 消息。
|
||||
func decodeBidiClientMessage(request *aiserverv1.BidiAppendRequest) (*agentv1.AgentClientMessage, string, error) {
|
||||
if request == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
if strings.TrimSpace(request.GetData()) != "" {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(request.GetData()))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode hex agent client message failed: %w", err)
|
||||
}
|
||||
message := &agentv1.AgentClientMessage{}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
|
||||
}
|
||||
return message, activeOneofName(message), nil
|
||||
}
|
||||
if len(request.GetDataBinary()) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
message := &agentv1.AgentClientMessage{}
|
||||
if err := proto.Unmarshal(request.GetDataBinary(), message); err != nil {
|
||||
return nil, "", fmt.Errorf("decode binary agent client message failed: %w", err)
|
||||
}
|
||||
return message, activeOneofName(message), nil
|
||||
}
|
||||
|
||||
// conversationIDFromClientMessage 从 Agent 消息的会话字段提取会话标识。
|
||||
func conversationIDFromClientMessage(message *agentv1.AgentClientMessage) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
if runRequest := message.GetRunRequest(); runRequest != nil {
|
||||
return strings.TrimSpace(runRequest.GetConversationId())
|
||||
}
|
||||
if prewarmRequest := message.GetPrewarmRequest(); prewarmRequest != nil {
|
||||
return strings.TrimSpace(prewarmRequest.GetConversationId())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// conversationIDFromUnaryRequest 从已知 RPC 请求中提取会话标识。
|
||||
func conversationIDFromUnaryRequest(message proto.Message) string {
|
||||
switch typed := message.(type) {
|
||||
case *agentv1.NotifyConversationCloneRequest:
|
||||
return strings.TrimSpace(typed.GetConversationId())
|
||||
case *agentv1.UploadConversationBlobsRequest:
|
||||
return strings.TrimSpace(typed.GetConversationId())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// decodeUnaryResponse 解析单次 RPC 响应并生成 JSON 视图。
|
||||
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
|
||||
message, kind := unaryResponseMessage(path)
|
||||
if message == nil {
|
||||
return "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, nil
|
||||
}
|
||||
|
||||
// hydrateStoredExchange 为历史捕获补齐正文和 Connect 帧视图。
|
||||
func hydrateStoredExchange(exchange *Exchange) bool {
|
||||
if exchange == nil || (exchange.State != "completed" && exchange.State != "streaming") {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if messageType := streamingRequestMessageType(exchange.Path); messageType != "" &&
|
||||
len(exchange.Request.Frames) == 0 && exchange.Request.RawHex != "" && !exchange.Request.RawTruncated {
|
||||
frames, err := decodeStoredConnectFrames(exchange.Request.RawHex, messageType, exchange.Request.ContentCodec)
|
||||
if err != nil {
|
||||
exchange.Request.DecodeError = err.Error()
|
||||
} else if len(frames) > 0 {
|
||||
exchange.Request.Frames = frames
|
||||
for _, frame := range frames {
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.RequestKind = frame.Kind
|
||||
}
|
||||
if frame.RequestID != "" {
|
||||
exchange.RequestID = frame.RequestID
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if messageType := streamingResponseMessageType(exchange.Path); messageType != "" &&
|
||||
len(exchange.Response.Frames) == 0 && exchange.Response.RawHex != "" && !exchange.Response.RawTruncated {
|
||||
frames, err := decodeStoredConnectFrames(exchange.Response.RawHex, messageType, exchange.Response.ContentCodec)
|
||||
if err != nil {
|
||||
exchange.Response.DecodeError = err.Error()
|
||||
} else if len(frames) > 0 {
|
||||
exchange.Response.Frames = frames
|
||||
exchange.FrameCount = len(frames)
|
||||
for _, frame := range frames {
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.ResponseKind = frame.Kind
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if isUnaryProtoContentType(exchange.Request.ContentType) && exchange.Request.DecodedJSON == "" && !exchange.Request.RawTruncated {
|
||||
payload, err := decodeStoredRawPayload(exchange.Request.RawHex, exchange.Request.ContentCodec)
|
||||
if err == nil {
|
||||
decoded, kind, requestID, conversationID, decodeErr := decodeUnaryRequest(exchange.Path, payload)
|
||||
if decodeErr != nil {
|
||||
err = decodeErr
|
||||
} else if decoded != "" {
|
||||
exchange.Request.DecodedJSON = decoded
|
||||
exchange.Request.DecodedLang = "json"
|
||||
exchange.RequestKind = kind
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if conversationID != "" {
|
||||
exchange.ConversationID = conversationID
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
exchange.Request.DecodeError = err.Error()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if isUnaryProtoContentType(exchange.Response.ContentType) && exchange.Response.DecodedJSON == "" && !exchange.Response.RawTruncated {
|
||||
payload, err := decodeStoredRawPayload(exchange.Response.RawHex, exchange.Response.ContentCodec)
|
||||
if err == nil {
|
||||
decoded, kind, decodeErr := decodeUnaryResponse(exchange.Path, payload)
|
||||
if decodeErr != nil {
|
||||
err = decodeErr
|
||||
} else if decoded != "" {
|
||||
exchange.Response.DecodedJSON = decoded
|
||||
exchange.Response.DecodedLang = "json"
|
||||
exchange.ResponseKind = kind
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
exchange.Response.DecodeError = err.Error()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if exchange.Request.DecodedJSON == "" && exchange.Request.RawHex != "" &&
|
||||
!isProtoContentType(exchange.Request.ContentType) && streamingRequestMessageType(exchange.Path) == "" {
|
||||
if hydrateStoredTextPayload(&exchange.Request) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if exchange.Response.DecodedJSON == "" && exchange.Response.RawHex != "" &&
|
||||
!isProtoContentType(exchange.Response.ContentType) && streamingResponseMessageType(exchange.Path) == "" {
|
||||
if hydrateStoredTextPayload(&exchange.Response) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hydrateStoredTextPayload 为历史文本载荷补齐 JSON 视图。
|
||||
func hydrateStoredTextPayload(payload *Payload) bool {
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(payload.RawHex))
|
||||
if err != nil {
|
||||
payload.DecodeError = fmt.Sprintf("解析已存储正文失败:%v", err)
|
||||
return true
|
||||
}
|
||||
decoded, language, decodeErr := decodeCapturedContent(raw, payload.ContentType, payload.ContentCodec)
|
||||
if decoded == "" && decodeErr == nil {
|
||||
return false
|
||||
}
|
||||
payload.DecodedJSON = decoded
|
||||
payload.DecodedLang = language
|
||||
if decodeErr != nil {
|
||||
payload.DecodeError = decodeErr.Error()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeCapturedContent 按媒体类型和压缩编码解码任意捕获正文。
|
||||
@@ -0,0 +1,397 @@
|
||||
// decode_stored.go 负责从持久化捕获记录恢复文本、帧和 protobuf 视图。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
|
||||
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/reflect/protoregistry"
|
||||
"google.golang.org/protobuf/types/dynamicpb"
|
||||
)
|
||||
|
||||
// decodeCapturedContent 按内容类型和压缩编码生成可读正文视图。
|
||||
func decodeCapturedContent(payload []byte, contentType, codec string) (string, string, error) {
|
||||
decoded, err := decodeHTTPContent(payload, codec)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
mediaType := normalizedMediaType(contentType)
|
||||
if json.Valid(decoded) {
|
||||
var formatted bytes.Buffer
|
||||
if err := json.Indent(&formatted, decoded, "", " "); err != nil {
|
||||
return string(decoded), "json", err
|
||||
}
|
||||
return formatted.String(), "json", nil
|
||||
}
|
||||
if mediaType == "application/x-www-form-urlencoded" && utf8.Valid(decoded) {
|
||||
values, parseErr := url.ParseQuery(string(decoded))
|
||||
if parseErr != nil {
|
||||
return string(decoded), "plaintext", parseErr
|
||||
}
|
||||
formatted, marshalErr := json.MarshalIndent(values, "", " ")
|
||||
return string(formatted), "json", marshalErr
|
||||
}
|
||||
if !isTextMediaType(mediaType) || !utf8.Valid(decoded) {
|
||||
return "", "", nil
|
||||
}
|
||||
if strings.ContainsRune(string(decoded), '\x00') {
|
||||
return "", "", nil
|
||||
}
|
||||
language := textLanguage(mediaType)
|
||||
if strings.HasSuffix(mediaType, "+json") || mediaType == "application/json" {
|
||||
return string(decoded), "json", fmt.Errorf("JSON 正文格式无效")
|
||||
}
|
||||
return string(decoded), language, nil
|
||||
}
|
||||
|
||||
// decodeHTTPContent 解压 HTTP 内容编码并返回正文副本。
|
||||
func decodeHTTPContent(payload []byte, codec string) ([]byte, error) {
|
||||
encodings := strings.Split(strings.TrimSpace(codec), ",")
|
||||
decoded := payload
|
||||
for index := len(encodings) - 1; index >= 0; index-- {
|
||||
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
|
||||
switch encoding {
|
||||
case "", "identity":
|
||||
case "gzip", "x-gzip":
|
||||
var err error
|
||||
decoded, err = decompressPayload(decoded, "gzip")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "deflate":
|
||||
reader, err := zlib.NewReader(bytes.NewReader(decoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deflate 解压失败:%w", err)
|
||||
}
|
||||
result, readErr := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("读取 deflate 内容失败:%w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("关闭 deflate 内容失败:%w", closeErr)
|
||||
}
|
||||
if len(result) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("deflate 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
decoded = result
|
||||
case "br":
|
||||
result, readErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(decoded)), maxConnectFrameBytes+1))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("读取 Brotli 内容失败:%w", readErr)
|
||||
}
|
||||
if len(result) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("Brotli 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
decoded = result
|
||||
default:
|
||||
return nil, fmt.Errorf("暂不支持内容编码 %q", encoding)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// normalizedMediaType 删除参数并统一媒体类型大小写。
|
||||
func normalizedMediaType(contentType string) string {
|
||||
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
|
||||
if err == nil {
|
||||
return strings.ToLower(mediaType)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||
}
|
||||
|
||||
// isProtoContentType 判断媒体类型是否表示 protobuf 二进制。
|
||||
func isProtoContentType(contentType string) bool {
|
||||
return strings.Contains(normalizedMediaType(contentType), "proto")
|
||||
}
|
||||
|
||||
// isTextMediaType 判断媒体类型是否适合直接作为文本展示。
|
||||
func isTextMediaType(mediaType string) bool {
|
||||
return strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") ||
|
||||
strings.HasSuffix(mediaType, "+xml") || mediaType == "application/json" ||
|
||||
mediaType == "application/xml" || mediaType == "application/javascript" ||
|
||||
mediaType == "application/x-javascript" || mediaType == "application/graphql"
|
||||
}
|
||||
|
||||
// textLanguage 为前端编辑器选择文本语言。
|
||||
func textLanguage(mediaType string) string {
|
||||
switch {
|
||||
case strings.Contains(mediaType, "json"):
|
||||
return "json"
|
||||
case strings.Contains(mediaType, "xml"):
|
||||
return "xml"
|
||||
case strings.Contains(mediaType, "html"):
|
||||
return "html"
|
||||
case strings.Contains(mediaType, "javascript"):
|
||||
return "javascript"
|
||||
case strings.Contains(mediaType, "css"):
|
||||
return "css"
|
||||
default:
|
||||
return "plaintext"
|
||||
}
|
||||
}
|
||||
|
||||
// decodeStoredConnectFrames 从持久化十六进制载荷恢复流式帧。
|
||||
func decodeStoredConnectFrames(rawHexValue, messageType, codec string) ([]FrameView, error) {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析已存储 Connect 正文失败:%w", err)
|
||||
}
|
||||
frames := make([]FrameView, 0)
|
||||
decoder := newConnectFrameDecoder(messageType, codec, defaultMaxFrames, func(frame FrameView) {
|
||||
frames = append(frames, frame)
|
||||
})
|
||||
decoder.Write(payload)
|
||||
decoder.Close()
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// isUnaryProtoContentType 判断媒体类型是否为可直接解码的 protobuf。
|
||||
func isUnaryProtoContentType(contentType string) bool {
|
||||
mediaType := normalizedMediaType(contentType)
|
||||
return mediaType == "application/proto" || mediaType == "application/protobuf" || mediaType == "application/x-protobuf"
|
||||
}
|
||||
|
||||
// decodeStoredRawPayload 解码持久化原始载荷并应用压缩处理。
|
||||
func decodeStoredRawPayload(rawHexValue, codec string) ([]byte, error) {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析已存储正文失败:%w", err)
|
||||
}
|
||||
if codec == "" || strings.EqualFold(codec, "identity") {
|
||||
return payload, nil
|
||||
}
|
||||
return decompressPayload(payload, codec)
|
||||
}
|
||||
|
||||
// unaryRequestMessage 根据 RPC 路径创建请求消息和稳定类型名。
|
||||
func unaryRequestMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
|
||||
case cppAvailableModelsPath:
|
||||
return &aiserverv1.AvailableCppModelsRequest{}, "available_cpp_models_request"
|
||||
case aiAvailableModelsPath:
|
||||
return &aiserverv1.AvailableModelsRequest{}, "available_models_request"
|
||||
case aiGetDefaultModelPath:
|
||||
return &aiserverv1.GetDefaultModelRequest{}, "get_default_model_request"
|
||||
case aiDefaultModelNudgeDataPath:
|
||||
return &aiserverv1.GetDefaultModelNudgeDataRequest{}, "get_default_model_nudge_data_request"
|
||||
case mcpGetKnownServersPath:
|
||||
return &aiserverv1.GetKnownServersRequest{}, "get_known_servers_request"
|
||||
case serverGetConfigPath:
|
||||
return &aiserverv1.GetServerConfigRequest{}, "get_server_config_request"
|
||||
default:
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
|
||||
return nil, ""
|
||||
}
|
||||
return dynamicpb.NewMessage(method.Input()), protoMessageKind(method.Input())
|
||||
}
|
||||
}
|
||||
|
||||
// unaryResponseMessage 根据 RPC 路径创建响应消息和稳定类型名。
|
||||
func unaryResponseMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
|
||||
case cppAvailableModelsPath:
|
||||
return &aiserverv1.AvailableCppModelsResponse{}, "available_cpp_models_response"
|
||||
case aiAvailableModelsPath:
|
||||
return &aiserverv1.AvailableModelsResponse{}, "available_models_response"
|
||||
case aiGetDefaultModelPath:
|
||||
return &aiserverv1.GetDefaultModelResponse{}, "get_default_model_response"
|
||||
case aiDefaultModelNudgeDataPath:
|
||||
return &aiserverv1.GetDefaultModelNudgeDataResponse{}, "get_default_model_nudge_data_response"
|
||||
case mcpGetKnownServersPath:
|
||||
return &aiserverv1.GetKnownServersResponse{}, "get_known_servers_response"
|
||||
case serverGetConfigPath:
|
||||
return &aiserverv1.GetServerConfigResponse{}, "get_server_config_response"
|
||||
default:
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
|
||||
return nil, ""
|
||||
}
|
||||
return dynamicpb.NewMessage(method.Output()), protoMessageKind(method.Output())
|
||||
}
|
||||
}
|
||||
|
||||
// streamingRequestMessageType 返回流式请求的 protobuf 类型名。
|
||||
func streamingRequestMessageType(path string) string {
|
||||
if path == runSSEPath {
|
||||
return "aiserver.v1.BidiRequestId"
|
||||
}
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
|
||||
return ""
|
||||
}
|
||||
return string(method.Input().FullName())
|
||||
}
|
||||
|
||||
// streamingResponseMessageType 返回流式响应的 protobuf 类型名。
|
||||
func streamingResponseMessageType(path string) string {
|
||||
if path == runSSEPath {
|
||||
return "agent.v1.AgentServerMessage"
|
||||
}
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
|
||||
return ""
|
||||
}
|
||||
return string(method.Output().FullName())
|
||||
}
|
||||
|
||||
// decodesUnaryRequest 判断是否存在已知的一元请求解码器。
|
||||
func decodesUnaryRequest(path string) bool {
|
||||
if path == bidiAppendPath {
|
||||
return true
|
||||
}
|
||||
message, _ := unaryRequestMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
// decodesUnaryResponse 判断是否存在已知的一元响应解码器。
|
||||
func decodesUnaryResponse(path string) bool {
|
||||
message, _ := unaryResponseMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
// newMessage 按完整 protobuf 类型名从注册表创建消息实例。
|
||||
func newMessage(messageType string) proto.Message {
|
||||
switch messageType {
|
||||
case "aiserver.v1.BidiRequestId":
|
||||
return &aiserverv1.BidiRequestId{}
|
||||
case "agent.v1.AgentServerMessage":
|
||||
return &agentv1.AgentServerMessage{}
|
||||
default:
|
||||
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(messageType))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return dynamicpb.NewMessage(messageDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// rpcMethodDescriptor 通过完整 RPC 路径查找注册表中的方法描述。
|
||||
func rpcMethodDescriptor(path string) protoreflect.MethodDescriptor {
|
||||
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil
|
||||
}
|
||||
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(parts[0]))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
service, ok := descriptor.(protoreflect.ServiceDescriptor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return service.Methods().ByName(protoreflect.Name(parts[1]))
|
||||
}
|
||||
|
||||
// protoMessageKind 从消息描述推导稳定的 JSON kind 名称。
|
||||
func protoMessageKind(descriptor protoreflect.MessageDescriptor) string {
|
||||
if descriptor == nil {
|
||||
return ""
|
||||
}
|
||||
return snakeCase(string(descriptor.Name()))
|
||||
}
|
||||
|
||||
// snakeCase 将 protobuf 名称转换为前端稳定的下划线命名。
|
||||
func snakeCase(value string) string {
|
||||
var result strings.Builder
|
||||
for index, character := range value {
|
||||
if unicode.IsUpper(character) {
|
||||
if index > 0 {
|
||||
result.WriteByte('_')
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(character))
|
||||
continue
|
||||
}
|
||||
result.WriteRune(character)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// marshalProtoJSON 把 protobuf 消息编码为前端可读 JSON。
|
||||
func marshalProtoJSON(message proto.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
payload, err := (protojson.MarshalOptions{
|
||||
UseProtoNames: true,
|
||||
EmitUnpopulated: false,
|
||||
Indent: " ",
|
||||
}).Marshal(message)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
// activeOneofName 返回 Agent 消息当前激活的 oneof 名称。
|
||||
func activeOneofName(message proto.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
reflected := message.ProtoReflect()
|
||||
oneofs := reflected.Descriptor().Oneofs()
|
||||
for index := 0; index < oneofs.Len(); index++ {
|
||||
oneof := oneofs.Get(index)
|
||||
field := reflected.WhichOneof(oneof)
|
||||
if field != nil {
|
||||
return string(field.Name())
|
||||
}
|
||||
}
|
||||
return string(reflected.Descriptor().Name())
|
||||
}
|
||||
|
||||
// prettyJSON 尝试格式化 JSON,失败时返回原始文本。
|
||||
func prettyJSON(payload []byte) string {
|
||||
var target any
|
||||
if err := json.Unmarshal(payload, &target); err != nil {
|
||||
return string(payload)
|
||||
}
|
||||
formatted, err := json.MarshalIndent(target, "", " ")
|
||||
if err != nil {
|
||||
return string(payload)
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
|
||||
// clippedHex 限制原始载荷展示长度并标记省略部分。
|
||||
func clippedHex(payload []byte, max int) string {
|
||||
if len(payload) > max {
|
||||
return hex.EncodeToString(payload[:max]) + "..."
|
||||
}
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module github.com/leookun/cursor-proxy-debugger
|
||||
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/leookun/cursor-byok/cursor-proto v0.0.0
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
google.golang.org/protobuf v1.36.11
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/leookun/cursor-byok/cursor-proto => ../cursor-proto
|
||||
@@ -0,0 +1,62 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,50 @@
|
||||
// cursor-proxy-debugger 提供独立 Cursor API 调试服务的进程入口。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
// main 解析启动参数,并管理调试服务的完整生命周期。
|
||||
func main() {
|
||||
config := Config{}
|
||||
openBrowser := true
|
||||
flag.StringVar(&config.ServiceAddr, "addr", defaultServiceAddr, "Cursor API 调试服务监听地址")
|
||||
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||
flag.StringVar(&config.DatabasePath, "db", "", "SQLite 数据库路径(默认使用用户配置目录)")
|
||||
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||
flag.Parse()
|
||||
|
||||
server, err := New(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Cursor API 调试服务已启动\n")
|
||||
fmt.Printf("服务地址: http://%s\n", server.ServiceAddr())
|
||||
fmt.Printf("固定上游: %s\n", defaultUpstreamURL)
|
||||
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||
fmt.Printf("SQLite: %s\n", server.DatabasePath())
|
||||
if openBrowser {
|
||||
_ = browser.OpenURL(server.UIURL())
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signals
|
||||
signal.Stop(signals)
|
||||
|
||||
if err := server.Close(); err != nil {
|
||||
log.Printf("关闭调试服务失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// server.go 负责固定上游服务、流量捕获和调试界面的生命周期。
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server 运行 Cursor API 转发服务及其本机调试界面。
|
||||
type Server struct {
|
||||
config Config
|
||||
upstream *url.URL
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
serviceServer *http.Server
|
||||
serviceLn net.Listener
|
||||
runMu sync.Mutex
|
||||
captureMu sync.RWMutex
|
||||
}
|
||||
|
||||
// New 创建固定转发到 Cursor API 的协议调试服务。
|
||||
func New(config Config) (*Server, error) {
|
||||
config = config.normalized()
|
||||
if err := validateLoopbackAddress(config.ServiceAddr); err != nil {
|
||||
return nil, fmt.Errorf("服务监听地址无效:%w", err)
|
||||
}
|
||||
upstream, err := url.Parse(defaultUpstreamURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析固定上游地址:%w", err)
|
||||
}
|
||||
store, err := newPersistentExchangeStore(config.DatabasePath, config.MaxExchanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
upstream: upstream,
|
||||
store: store,
|
||||
}
|
||||
server.counter.Store(store.maxNumericID())
|
||||
server.serviceServer = &http.Server{
|
||||
Handler: server.newServiceHandler(),
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start 启动同时承载 API 转发和调试界面的单端口服务。
|
||||
func (server *Server) Start() error {
|
||||
server.runMu.Lock()
|
||||
defer server.runMu.Unlock()
|
||||
if server.serviceLn != nil {
|
||||
return errors.New("Cursor API 调试服务已经启动")
|
||||
}
|
||||
serviceListener, err := net.Listen("tcp", server.config.ServiceAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动 API 服务监听失败:%w", err)
|
||||
}
|
||||
server.serviceLn = serviceListener
|
||||
go func() { _ = server.serviceServer.Serve(serviceListener) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 立即关闭监听器、活跃连接并释放捕获存储。
|
||||
func (server *Server) Close() error {
|
||||
server.runMu.Lock()
|
||||
serviceServer := server.serviceServer
|
||||
server.serviceLn = nil
|
||||
server.runMu.Unlock()
|
||||
var errorsList []error
|
||||
if serviceServer != nil {
|
||||
if err := serviceServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
if server.store != nil {
|
||||
if err := server.store.close(); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errorsList...)
|
||||
}
|
||||
|
||||
// ServiceAddr 返回 Cursor API 服务监听地址。
|
||||
func (server *Server) ServiceAddr() string { return server.config.ServiceAddr }
|
||||
|
||||
// UIURL 返回可在浏览器中打开的调试界面地址。
|
||||
func (server *Server) UIURL() string {
|
||||
return "http://" + browserAddress(server.config.ServiceAddr) + debugBasePath + "/"
|
||||
}
|
||||
|
||||
// DatabasePath 返回捕获数据库路径。
|
||||
func (server *Server) DatabasePath() string {
|
||||
return server.config.DatabasePath
|
||||
}
|
||||
|
||||
// newServiceHandler 创建单端口调试路由和固定上游流式转发。
|
||||
func (server *Server) newServiceHandler() http.Handler {
|
||||
reverseProxy := httputil.NewSingleHostReverseProxy(server.upstream)
|
||||
reverseProxy.FlushInterval = -1
|
||||
reverseProxy.ErrorLog = log.New(io.Discard, "", 0)
|
||||
originalDirector := reverseProxy.Director
|
||||
reverseProxy.Director = func(request *http.Request) {
|
||||
originalDirector(request)
|
||||
request.Host = server.upstream.Host
|
||||
request.Header["X-Forwarded-For"] = nil
|
||||
}
|
||||
reverseProxy.Transport = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
DisableCompression: true,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
reverseProxy.ModifyResponse = server.captureResponse
|
||||
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, upstreamErr error) {
|
||||
server.failExchange(request, upstreamErr)
|
||||
http.Error(writer, "Cursor API upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
forwardHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
reverseProxy.ServeHTTP(writer, server.captureRequest(request))
|
||||
})
|
||||
debugHandler := http.StripPrefix(debugBasePath, server.newUIHandler())
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(debugBasePath+"/", debugHandler)
|
||||
mux.HandleFunc(debugBasePath, func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(writer, request, debugBasePath+"/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
mux.Handle("/", forwardHandler)
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// store.go 管理调试捕获的内存索引、SQLite 持久化和订阅通知。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// exchangeStore 保存有限内存窗口以及可选的持久化数据库连接。
|
||||
type exchangeStore struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
order []string
|
||||
exchanges map[string]*Exchange
|
||||
subscribers map[chan storeEvent]struct{}
|
||||
db *sql.DB
|
||||
databasePath string
|
||||
lastError string
|
||||
}
|
||||
|
||||
// newExchangeStore 创建仅使用内存的捕获存储。
|
||||
func newExchangeStore(max int) *exchangeStore {
|
||||
return &exchangeStore{
|
||||
max: max,
|
||||
exchanges: make(map[string]*Exchange),
|
||||
subscribers: make(map[chan storeEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// newPersistentExchangeStore 创建 SQLite 持久化捕获存储并恢复最近记录。
|
||||
func newPersistentExchangeStore(path string, max int) (*exchangeStore, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SQLite 数据库路径不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开 SQLite 数据库失败: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
store := newExchangeStore(max)
|
||||
store.db = db
|
||||
store.databasePath = path
|
||||
if err := store.initializeDatabase(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.backfillDecodedExchanges(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadRecent(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// backfillDecodedExchanges 为旧记录补齐解码视图并写回数据库。
|
||||
func (store *exchangeStore) backfillDecodedExchanges(ctx context.Context) error {
|
||||
rows, err := store.db.QueryContext(ctx, "SELECT payload_json FROM exchanges")
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取待回填的 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
var exchanges []Exchange
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("解析待回填的 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
if hydrateStoredExchange(&exchange) {
|
||||
exchanges = append(exchanges, exchange)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(exchanges) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
for index := range exchanges {
|
||||
payload, marshalErr := json.Marshal(&exchanges[index])
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE exchanges SET payload_json = ?, conversation_id = ?,
|
||||
request_id = ?, updated_at_ms = ? WHERE id = ?`, payload, exchanges[index].ConversationID,
|
||||
exchanges[index].RequestID, time.Now().UnixMilli(), exchanges[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeDatabase 创建调试器使用的 SQLite 表结构。
|
||||
func (store *exchangeStore) initializeDatabase(ctx context.Context) error {
|
||||
for _, statement := range []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
"PRAGMA secure_delete = ON",
|
||||
`CREATE TABLE IF NOT EXISTS exchanges (
|
||||
id TEXT PRIMARY KEY,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
request_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json BLOB NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS exchanges_conversation_started_idx ON exchanges(conversation_id, started_at_ms DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS exchanges_request_idx ON exchanges(request_id)",
|
||||
} {
|
||||
if _, err := store.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("初始化 SQLite 数据库失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRecent 从数据库恢复内存窗口中的最新捕获。
|
||||
func (store *exchangeStore) loadRecent(ctx context.Context) error {
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT payload_json, conversation_id
|
||||
FROM exchanges ORDER BY started_at_ms DESC, id DESC LIMIT ?`, store.max)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
var conversationID string
|
||||
if err := rows.Scan(&payload, &conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
return fmt.Errorf("解析 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
exchange.ConversationID = conversationID
|
||||
store.exchanges[exchange.ID] = &exchange
|
||||
store.order = append(store.order, exchange.ID)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// create 添加一条新的捕获并通知订阅者。
|
||||
func (store *exchangeStore) create(exchange *Exchange) {
|
||||
store.mu.Lock()
|
||||
store.exchanges[exchange.ID] = exchange
|
||||
store.order = append([]string{exchange.ID}, store.order...)
|
||||
for len(store.order) > store.max {
|
||||
oldest := store.order[len(store.order)-1]
|
||||
store.order = store.order[:len(store.order)-1]
|
||||
delete(store.exchanges, oldest)
|
||||
}
|
||||
store.persistLocked(exchange)
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "created", ID: exchange.ID})
|
||||
}
|
||||
|
||||
// update 持久化修改并发布最终捕获快照。
|
||||
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
|
||||
store.updateWithPersistence(id, apply, true)
|
||||
}
|
||||
|
||||
// updateTransient 只更新内存并发布流式过程快照。
|
||||
func (store *exchangeStore) updateTransient(id string, apply func(*Exchange)) {
|
||||
store.updateWithPersistence(id, apply, false)
|
||||
}
|
||||
|
||||
// updateWithPersistence 在统一锁内完成修改、关联和可选持久化。
|
||||
func (store *exchangeStore) updateWithPersistence(id string, apply func(*Exchange), persist bool) {
|
||||
store.mu.Lock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange == nil && store.db != nil {
|
||||
var err error
|
||||
exchange, err = store.loadPersistedLocked(id)
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
if exchange != nil {
|
||||
store.exchanges[id] = exchange
|
||||
store.order = append([]string{id}, store.order...)
|
||||
for len(store.order) > store.max {
|
||||
oldest := store.order[len(store.order)-1]
|
||||
store.order = store.order[:len(store.order)-1]
|
||||
delete(store.exchanges, oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
if exchange != nil {
|
||||
previousRequestID := exchange.RequestID
|
||||
previousConversationID := exchange.ConversationID
|
||||
apply(exchange)
|
||||
if exchange.RequestID != previousRequestID || exchange.ConversationID != previousConversationID {
|
||||
store.associateConversationLocked(exchange)
|
||||
}
|
||||
if persist {
|
||||
store.persistLocked(exchange)
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "updated", ID: id})
|
||||
}
|
||||
|
||||
// summaries 返回按时间倒序排列的请求摘要。
|
||||
@@ -0,0 +1,354 @@
|
||||
// store_queries.go 负责调试捕获记录的查询、持久化辅助和订阅通知。
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// summaries 返回指定会话的捕获摘要列表。
|
||||
func (store *exchangeStore) summaries(conversationID string) ([]ExchangeSummary, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
if store.db != nil {
|
||||
query := `SELECT payload_json, conversation_id FROM exchanges`
|
||||
arguments := []any{}
|
||||
if conversationID != "" {
|
||||
query += " WHERE conversation_id = ?"
|
||||
arguments = append(arguments, conversationID)
|
||||
}
|
||||
query += " ORDER BY started_at_ms DESC, id DESC"
|
||||
rows, err := store.db.Query(query, arguments...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ExchangeSummary, 0)
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
var persistedConversationID string
|
||||
if err := rows.Scan(&payload, &persistedConversationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exchange.ConversationID = persistedConversationID
|
||||
if current := store.exchanges[exchange.ID]; current != nil {
|
||||
result = append(result, current.ExchangeSummary)
|
||||
} else {
|
||||
result = append(result, exchange.ExchangeSummary)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
result := make([]ExchangeSummary, 0, len(store.order))
|
||||
for _, id := range store.order {
|
||||
if exchange := store.exchanges[id]; exchange != nil {
|
||||
result = append(result, exchange.ExchangeSummary)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// get 返回内存或数据库中的完整捕获副本。
|
||||
func (store *exchangeStore) get(id string) (Exchange, bool, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange != nil {
|
||||
return cloneExchange(*exchange), true, nil
|
||||
}
|
||||
if store.db == nil {
|
||||
return Exchange{}, false, nil
|
||||
}
|
||||
persisted, err := store.loadPersistedLocked(id)
|
||||
if err != nil {
|
||||
return Exchange{}, false, err
|
||||
}
|
||||
if persisted == nil {
|
||||
return Exchange{}, false, nil
|
||||
}
|
||||
return *persisted, true, nil
|
||||
}
|
||||
|
||||
// loadPersistedLocked 从 SQLite 读取单条捕获并在必要时解码回填。
|
||||
func (store *exchangeStore) loadPersistedLocked(id string) (*Exchange, error) {
|
||||
var payload []byte
|
||||
var conversationID string
|
||||
err := store.db.QueryRow("SELECT payload_json, conversation_id FROM exchanges WHERE id = ?", id).Scan(&payload, &conversationID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var persisted Exchange
|
||||
if err := json.Unmarshal(payload, &persisted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
persisted.ConversationID = conversationID
|
||||
return &persisted, nil
|
||||
}
|
||||
|
||||
// clear 清除数据库、内存索引和会话关联。
|
||||
func (store *exchangeStore) clear() error {
|
||||
store.mu.Lock()
|
||||
var err error
|
||||
if store.db != nil {
|
||||
_, err = store.db.Exec("DELETE FROM exchanges")
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
store.order = nil
|
||||
store.exchanges = make(map[string]*Exchange)
|
||||
store.lastError = ""
|
||||
}
|
||||
store.mu.Unlock()
|
||||
if err == nil {
|
||||
store.publish(storeEvent{Type: "cleared"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// conversations 按会话聚合持久化捕获统计。
|
||||
func (store *exchangeStore) conversations() ([]ConversationSummary, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
if store.db == nil {
|
||||
groups := make(map[string]*ConversationSummary)
|
||||
for _, exchange := range store.exchanges {
|
||||
group := groups[exchange.ConversationID]
|
||||
if group == nil {
|
||||
group = &ConversationSummary{ConversationID: exchange.ConversationID}
|
||||
groups[exchange.ConversationID] = group
|
||||
}
|
||||
group.ExchangeCount++
|
||||
group.RequestBytes += exchange.RequestBytes
|
||||
group.ResponseBytes += exchange.ResponseBytes
|
||||
if exchange.StartedAt.After(group.LastStartedAt) {
|
||||
group.LastStartedAt = exchange.StartedAt
|
||||
}
|
||||
}
|
||||
result := make([]ConversationSummary, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
result = append(result, *group)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].LastStartedAt.After(result[j].LastStartedAt) })
|
||||
return result, nil
|
||||
}
|
||||
rows, err := store.db.Query(`SELECT conversation_id, COUNT(*), MAX(started_at_ms),
|
||||
COALESCE(SUM(request_bytes), 0), COALESCE(SUM(response_bytes), 0)
|
||||
FROM exchanges GROUP BY conversation_id ORDER BY MAX(started_at_ms) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ConversationSummary, 0)
|
||||
for rows.Next() {
|
||||
var summary ConversationSummary
|
||||
var startedAtMS int64
|
||||
if err := rows.Scan(&summary.ConversationID, &summary.ExchangeCount, &startedAtMS, &summary.RequestBytes, &summary.ResponseBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.LastStartedAt = time.UnixMilli(startedAtMS)
|
||||
result = append(result, summary)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// persistLocked 将当前捕获快照写入 SQLite。
|
||||
func (store *exchangeStore) persistLocked(exchange *Exchange) {
|
||||
if store.db == nil || exchange == nil {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(exchange)
|
||||
if err == nil {
|
||||
_, err = store.db.Exec(`INSERT INTO exchanges (
|
||||
id, started_at_ms, conversation_id, request_id, state, request_bytes,
|
||||
response_bytes, payload_json, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
started_at_ms = excluded.started_at_ms,
|
||||
conversation_id = excluded.conversation_id,
|
||||
request_id = excluded.request_id,
|
||||
state = excluded.state,
|
||||
request_bytes = excluded.request_bytes,
|
||||
response_bytes = excluded.response_bytes,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at_ms = excluded.updated_at_ms`,
|
||||
exchange.ID, exchange.StartedAt.UnixMilli(), exchange.ConversationID,
|
||||
exchange.RequestID, exchange.State, exchange.RequestBytes,
|
||||
exchange.ResponseBytes, payload, time.Now().UnixMilli())
|
||||
}
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
} else {
|
||||
store.lastError = ""
|
||||
}
|
||||
}
|
||||
|
||||
// associateConversationLocked 根据请求标识补齐会话关联。
|
||||
func (store *exchangeStore) associateConversationLocked(exchange *Exchange) {
|
||||
if exchange.RequestID == "" {
|
||||
return
|
||||
}
|
||||
if exchange.ConversationID == "" {
|
||||
for _, candidate := range store.exchanges {
|
||||
if candidate.RequestID == exchange.RequestID && candidate.ConversationID != "" {
|
||||
exchange.ConversationID = candidate.ConversationID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if exchange.ConversationID == "" && store.db != nil {
|
||||
_ = store.db.QueryRow(`SELECT conversation_id FROM exchanges
|
||||
WHERE request_id = ? AND conversation_id != ''
|
||||
ORDER BY started_at_ms DESC LIMIT 1`, exchange.RequestID).Scan(&exchange.ConversationID)
|
||||
}
|
||||
if exchange.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
for _, candidate := range store.exchanges {
|
||||
if candidate.RequestID == exchange.RequestID && candidate.ConversationID == "" {
|
||||
candidate.ConversationID = exchange.ConversationID
|
||||
store.persistLocked(candidate)
|
||||
}
|
||||
}
|
||||
if store.db != nil {
|
||||
if _, err := store.db.Exec(`UPDATE exchanges SET conversation_id = ?, updated_at_ms = ?
|
||||
WHERE request_id = ? AND conversation_id = ''`, exchange.ConversationID, time.Now().UnixMilli(), exchange.RequestID); err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maxNumericID 返回数据库中已使用的最大数字捕获编号。
|
||||
func (store *exchangeStore) maxNumericID() uint64 {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
var maximum uint64
|
||||
if store.db != nil {
|
||||
_ = store.db.QueryRow("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) FROM exchanges").Scan(&maximum)
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
// close 关闭数据库连接并终止后续订阅通知。
|
||||
func (store *exchangeStore) close() error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if store.db == nil {
|
||||
return nil
|
||||
}
|
||||
err := store.db.Close()
|
||||
store.db = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// status 返回数据库路径和最近一次数据库错误。
|
||||
func (store *exchangeStore) status() (string, string) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
return store.databasePath, store.lastError
|
||||
}
|
||||
|
||||
// subscribe 注册一个捕获变化订阅者。
|
||||
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
|
||||
updates := make(chan storeEvent, 32)
|
||||
store.mu.Lock()
|
||||
store.subscribers[updates] = struct{}{}
|
||||
store.mu.Unlock()
|
||||
return updates, func() {
|
||||
store.mu.Lock()
|
||||
if _, ok := store.subscribers[updates]; ok {
|
||||
delete(store.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// publish 非阻塞地广播捕获变化事件。
|
||||
func (store *exchangeStore) publish(event storeEvent) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
for subscriber := range store.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneExchange 深拷贝捕获及其请求响应载荷。
|
||||
func cloneExchange(exchange Exchange) Exchange {
|
||||
exchange.Request = clonePayload(exchange.Request)
|
||||
exchange.Response = clonePayload(exchange.Response)
|
||||
return exchange
|
||||
}
|
||||
|
||||
// clonePayload 深拷贝头信息和 Connect 帧切片。
|
||||
func clonePayload(payload Payload) Payload {
|
||||
payload.Headers = append([]Header(nil), payload.Headers...)
|
||||
payload.Frames = append([]FrameView(nil), payload.Frames...)
|
||||
return payload
|
||||
}
|
||||
|
||||
// elapsedMS 计算从开始时间到当前时间的毫秒耗时。
|
||||
func elapsedMS(startedAt time.Time) int64 {
|
||||
if startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
// sortedHeaders 生成脱敏且按名称排序的请求头列表。
|
||||
func sortedHeaders(headers map[string][]string) []Header {
|
||||
result := make([]Header, 0, len(headers))
|
||||
for name, values := range headers {
|
||||
value := ""
|
||||
for index, item := range values {
|
||||
if index > 0 {
|
||||
value += ", "
|
||||
}
|
||||
value += item
|
||||
}
|
||||
if isSensitiveHeader(name) && value != "" {
|
||||
value = "[已隐藏]"
|
||||
}
|
||||
result = append(result, Header{Name: name, Value: value})
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
return result[left].Name < result[right].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// isSensitiveHeader 判断请求头是否包含鉴权或隐私信息。
|
||||
func isSensitiveHeader(name string) bool {
|
||||
switch httpCanonicalLower(name) {
|
||||
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// httpCanonicalLower 将请求头名称规范化为小写形式。
|
||||
func httpCanonicalLower(value string) string {
|
||||
buffer := make([]byte, len(value))
|
||||
for index := range value {
|
||||
character := value[index]
|
||||
if character >= 'A' && character <= 'Z' {
|
||||
character += 'a' - 'A'
|
||||
}
|
||||
buffer[index] = character
|
||||
}
|
||||
return string(buffer)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// types.go 定义协议调试器配置、捕获详情和会话摘要模型。
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 默认值限制调试器只监听本机并约束内存捕获规模。
|
||||
const (
|
||||
defaultServiceAddr = "127.0.0.1:9090"
|
||||
defaultUpstreamURL = "https://api2.cursor.sh"
|
||||
debugBasePath = "/__debuger__"
|
||||
defaultMaxExchanges = 200
|
||||
defaultMaxCaptureBytes = 2 << 20
|
||||
defaultMaxFrames = 2000
|
||||
defaultDatabaseName = "cursor-proxy-debugger.db"
|
||||
)
|
||||
|
||||
// Config 控制独立协议调试器的监听和存储限制。
|
||||
type Config struct {
|
||||
// ServiceAddr 是 Cursor API 调试服务监听地址。
|
||||
ServiceAddr string
|
||||
// MaxExchanges 是内存保留的最大请求数。
|
||||
MaxExchanges int
|
||||
// MaxCaptureBytes 是单向载荷保存上限。
|
||||
MaxCaptureBytes int
|
||||
// MaxFrames 是单条流保存的 Connect 帧上限。
|
||||
MaxFrames int
|
||||
// DatabasePath 是 SQLite 捕获数据库路径。
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
// normalized 补齐空值并拒绝无效的容量配置。
|
||||
func (config Config) normalized() Config {
|
||||
if config.ServiceAddr == "" {
|
||||
config.ServiceAddr = defaultServiceAddr
|
||||
}
|
||||
if config.MaxExchanges <= 0 {
|
||||
config.MaxExchanges = defaultMaxExchanges
|
||||
}
|
||||
if config.MaxCaptureBytes <= 0 {
|
||||
config.MaxCaptureBytes = defaultMaxCaptureBytes
|
||||
}
|
||||
if config.MaxFrames <= 0 {
|
||||
config.MaxFrames = defaultMaxFrames
|
||||
}
|
||||
if config.DatabasePath == "" {
|
||||
config.DatabasePath = defaultDatabasePath()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// defaultDatabasePath 返回当前用户配置目录下的默认数据库路径。
|
||||
func defaultDatabasePath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil || configDir == "" {
|
||||
return defaultDatabaseName
|
||||
}
|
||||
return filepath.Join(configDir, "cursor-byok", defaultDatabaseName)
|
||||
}
|
||||
|
||||
// ExchangeSummary 是请求列表使用的紧凑捕获摘要。
|
||||
type ExchangeSummary struct {
|
||||
// ID 是进程内递增的捕获标识。
|
||||
ID string `json:"id"`
|
||||
// StartedAt 是请求开始时间。
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
// Method 是 HTTP 方法。
|
||||
Method string `json:"method"`
|
||||
// URL 是完整请求地址。
|
||||
URL string `json:"url"`
|
||||
// Host 是请求目标主机。
|
||||
Host string `json:"host"`
|
||||
// Path 是 RPC 或 HTTP 路径。
|
||||
Path string `json:"path"`
|
||||
// Status 是 HTTP 响应状态码。
|
||||
Status int `json:"status"`
|
||||
// State 是捕获处理阶段。
|
||||
State string `json:"state"`
|
||||
// DurationMS 是请求总耗时毫秒数。
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
// RequestBytes 是完整请求体字节数。
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
// ResponseBytes 是完整响应体字节数。
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
// RequestID 是协议请求标识。
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
// ConversationID 是关联会话标识。
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
// RequestKind 是解码后的请求消息类型。
|
||||
RequestKind string `json:"requestKind,omitempty"`
|
||||
// ResponseKind 是解码后的响应消息类型。
|
||||
ResponseKind string `json:"responseKind,omitempty"`
|
||||
// FrameCount 是双向 Connect 帧总数。
|
||||
FrameCount int `json:"frameCount"`
|
||||
// Error 是转发或解码错误。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Exchange 保存调试界面展示的请求和响应详情。
|
||||
type Exchange struct {
|
||||
ExchangeSummary
|
||||
// Request 是请求方向载荷。
|
||||
Request Payload `json:"request"`
|
||||
// Response 是响应方向载荷。
|
||||
Response Payload `json:"response"`
|
||||
}
|
||||
|
||||
// Payload 保存请求头、原始副本、解码正文和协议帧。
|
||||
type Payload struct {
|
||||
// Headers 是脱敏且排序稳定的 HTTP 请求头。
|
||||
Headers []Header `json:"headers"`
|
||||
// ContentType 是规范化媒体类型。
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
// ContentCodec 是内容压缩算法。
|
||||
ContentCodec string `json:"contentCodec,omitempty"`
|
||||
// Size 是完整方向载荷字节数。
|
||||
Size int64 `json:"size"`
|
||||
// RawHex 是受限原始副本的十六进制文本。
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
// RawTruncated 表示原始副本达到保存上限。
|
||||
RawTruncated bool `json:"rawTruncated,omitempty"`
|
||||
// DecodedJSON 是格式化后的结构化正文。
|
||||
DecodedJSON string `json:"decodedJson,omitempty"`
|
||||
// DecodedLang 是前端编辑器使用的语言标识。
|
||||
DecodedLang string `json:"decodedLanguage,omitempty"`
|
||||
// DecodeError 是不影响转发的解码错误。
|
||||
DecodeError string `json:"decodeError,omitempty"`
|
||||
// Frames 是 Connect 流的逐帧视图。
|
||||
Frames []FrameView `json:"frames,omitempty"`
|
||||
}
|
||||
|
||||
// Header 是排序稳定的 HTTP 请求头键值对。
|
||||
type Header struct {
|
||||
// Name 是请求头名称。
|
||||
Name string `json:"name"`
|
||||
// Value 是已脱敏的请求头值。
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// FrameView 描述一条 Connect 流式信封。
|
||||
type FrameView struct {
|
||||
// Index 是帧在当前方向的序号。
|
||||
Index int `json:"index"`
|
||||
// Flags 是 Connect 原始标志位。
|
||||
Flags uint8 `json:"flags"`
|
||||
// Length 是解压前帧载荷长度。
|
||||
Length int `json:"length"`
|
||||
// Compressed 表示帧载荷使用压缩。
|
||||
Compressed bool `json:"compressed"`
|
||||
// EndStream 表示帧携带流结束标志。
|
||||
EndStream bool `json:"endStream"`
|
||||
// Kind 是解码后的业务消息类型。
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// MessageType 是 protobuf 完整消息名。
|
||||
MessageType string `json:"messageType,omitempty"`
|
||||
// RequestID 是帧中解析出的请求标识。
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
// JSON 是 protobuf 的 JSON 视图。
|
||||
JSON string `json:"json,omitempty"`
|
||||
// RawHex 是无法解码时保留的载荷文本。
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
// Error 是当前帧的解压或解码错误。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// storeEvent 是 SSE 通知使用的最小变化事件。
|
||||
type storeEvent struct {
|
||||
// Type 是捕获记录变化类型。
|
||||
Type string `json:"type"`
|
||||
// ID 是关联捕获标识。
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// ConversationSummary 描述按会话聚合的持久化流量。
|
||||
type ConversationSummary struct {
|
||||
// ConversationID 是会话稳定标识。
|
||||
ConversationID string `json:"conversationId"`
|
||||
// ExchangeCount 是会话捕获记录数。
|
||||
ExchangeCount int `json:"exchangeCount"`
|
||||
// LastStartedAt 是会话最近请求时间。
|
||||
LastStartedAt time.Time `json:"lastStartedAt"`
|
||||
// RequestBytes 是会话累计请求字节数。
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
// ResponseBytes 是会话累计响应字节数。
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package proxydebugger
|
||||
// web.go 提供调试器只读 API、SSE 更新流和内嵌静态页面。
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
@@ -8,43 +9,60 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
// webAssets 保存无需外部文件即可启动的调试页面资源。
|
||||
//
|
||||
//go:embed web/*
|
||||
var webAssets embed.FS
|
||||
|
||||
// newUIHandler 注册只绑定本机界面的调试 API 和静态资源。
|
||||
func (server *Server) newUIHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/status", server.handleStatus)
|
||||
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
|
||||
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
|
||||
mux.HandleFunc("GET /api/conversations", server.handleConversationList)
|
||||
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
|
||||
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
|
||||
assets, _ := fs.Sub(webAssets, "web")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/", fileServer)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
// handleStatus 返回监听地址、固定上游和数据库状态。
|
||||
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||
databasePath, databaseError := server.store.status()
|
||||
writeJSON(writer, http.StatusOK, map[string]any{
|
||||
"proxyAddr": server.config.ProxyAddr,
|
||||
"uiAddr": server.config.UIAddr,
|
||||
"targetHost": server.config.TargetHost,
|
||||
"running": true,
|
||||
"serviceAddr": server.config.ServiceAddr,
|
||||
"debugPath": debugBasePath + "/",
|
||||
"upstreamURL": server.upstream.String(),
|
||||
"running": true,
|
||||
"databasePath": databasePath,
|
||||
"databaseError": databaseError,
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, server.store.summaries())
|
||||
// handleExchangeList 按可选会话标识列出请求摘要。
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, request *http.Request) {
|
||||
conversationID := strings.TrimSpace(request.URL.Query().Get("conversation_id"))
|
||||
summaries, err := server.store.summaries(conversationID)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// handleExchangeDetail 返回单条请求的完整捕获详情。
|
||||
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||
id := strings.TrimSpace(request.PathValue("id"))
|
||||
exchange, ok := server.store.get(id)
|
||||
exchange, ok, err := server.store.get(id)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||
return
|
||||
@@ -52,11 +70,26 @@ func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *
|
||||
writeJSON(writer, http.StatusOK, exchange)
|
||||
}
|
||||
|
||||
// handleClearExchanges 清除内存和 SQLite 中的捕获记录。
|
||||
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||
server.store.clear()
|
||||
if err := server.clearExchanges(); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleConversationList 返回持久化流量的会话分组。
|
||||
func (server *Server) handleConversationList(writer http.ResponseWriter, _ *http.Request) {
|
||||
conversations, err := server.store.conversations()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, conversations)
|
||||
}
|
||||
|
||||
// handleEvents 通过 SSE 推送捕获记录变化和保活心跳。
|
||||
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := writer.(http.Flusher)
|
||||
if !ok {
|
||||
@@ -90,23 +123,19 @@ func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Req
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||
_, _ = writer.Write(certs.EmbeddedCACertPEM())
|
||||
}
|
||||
|
||||
// writeJSON 写入统一 JSON 响应。
|
||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// securityHeaders 为本地调试页面添加最小浏览器安全策略。
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; worker-src 'self' blob:")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。
|
||||
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
|
||||
import { bindEvents, renderPauseState } from "./app_events.js";
|
||||
import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js";
|
||||
const monacoReady = loadMonaco();
|
||||
const editorSlots = {
|
||||
request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
|
||||
response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
|
||||
};
|
||||
|
||||
const state = {
|
||||
status: null,
|
||||
exchanges: [],
|
||||
conversations: [],
|
||||
selectedId: null,
|
||||
selected: null,
|
||||
search: "",
|
||||
requestId: "",
|
||||
conversationId: "",
|
||||
endpoint: "all",
|
||||
bidiMessageKinds: new Set(),
|
||||
showOptions: false,
|
||||
sortOrder: "desc",
|
||||
paused: false,
|
||||
pendingRefresh: false,
|
||||
connection: { connected: false, key: "status.connecting", values: {} },
|
||||
tabs: {
|
||||
request: "body",
|
||||
response: "body",
|
||||
},
|
||||
};
|
||||
|
||||
const elements = {
|
||||
statusDot: document.querySelector("#status-dot"),
|
||||
statusText: document.querySelector("#status-text"),
|
||||
serviceAddress: document.querySelector("#service-address"),
|
||||
upstreamURL: document.querySelector("#upstream-url"),
|
||||
connectionLabel: document.querySelector("#connection-label"),
|
||||
trafficSummary: document.querySelector("#traffic-summary"),
|
||||
searchInput: document.querySelector("#search-input"),
|
||||
requestIdInput: document.querySelector("#request-id-input"),
|
||||
conversationSelect: document.querySelector("#conversation-select"),
|
||||
endpointFilter: document.querySelector("#endpoint-filter"),
|
||||
bidiMessageFilter: document.querySelector("#bidi-message-filter"),
|
||||
bidiMessageOptions: document.querySelector("#bidi-message-options"),
|
||||
showOptionsCheckbox: document.querySelector("#show-options-checkbox"),
|
||||
sortOrder: document.querySelector("#sort-order"),
|
||||
requestCount: document.querySelector("#request-count"),
|
||||
requestList: document.querySelector("#request-list"),
|
||||
emptyState: document.querySelector("#empty-state"),
|
||||
selectionSummary: document.querySelector("#selection-summary"),
|
||||
requestContent: document.querySelector("#request-content"),
|
||||
responseContent: document.querySelector("#response-content"),
|
||||
pauseButton: document.querySelector("#pause-button"),
|
||||
clearButton: document.querySelector("#clear-button"),
|
||||
localeSelect: document.querySelector("#locale-select"),
|
||||
workspace: document.querySelector("#workspace"),
|
||||
splitter: document.querySelector("#horizontal-splitter"),
|
||||
};
|
||||
|
||||
async function fetchJSON(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
state.status = await fetchJSON("api/status");
|
||||
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
|
||||
renderRuntimeStatus();
|
||||
elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`;
|
||||
elements.upstreamURL.textContent = state.status.upstreamURL;
|
||||
elements.showOptionsCheckbox.checked = state.showOptions;
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : "";
|
||||
[state.exchanges, state.conversations] = await Promise.all([
|
||||
fetchJSON(`api/exchanges${query}`),
|
||||
fetchJSON("api/conversations"),
|
||||
]);
|
||||
renderConversationOptions();
|
||||
renderBidiMessageFilter();
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
|
||||
await refreshDetail(state.selectedId);
|
||||
} else if (state.selectedId) {
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
renderDetail();
|
||||
}
|
||||
}
|
||||
|
||||
function renderConversationOptions() {
|
||||
const selected = state.conversationId;
|
||||
const options = [`<option value="">${escapeHTML(t("filters.allConversations"))}</option>`];
|
||||
for (const conversation of state.conversations) {
|
||||
if (!conversation.conversationId) continue;
|
||||
const label = `${conversation.conversationId} (${conversation.exchangeCount})`;
|
||||
options.push(`<option value="${escapeHTML(conversation.conversationId)}">${escapeHTML(label)}</option>`);
|
||||
}
|
||||
elements.conversationSelect.innerHTML = options.join("");
|
||||
elements.conversationSelect.value = selected;
|
||||
}
|
||||
|
||||
async function refreshDetail(id) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`);
|
||||
if (state.selectedId !== id) return;
|
||||
state.selected = detail;
|
||||
renderDetail();
|
||||
} catch (error) {
|
||||
if (state.selectedId === id) {
|
||||
state.selected = null;
|
||||
renderDetailError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (state.paused) {
|
||||
state.pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
if (state.pendingRefresh) return;
|
||||
state.pendingRefresh = true;
|
||||
window.setTimeout(async () => {
|
||||
state.pendingRefresh = false;
|
||||
try {
|
||||
await refreshList();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.refreshFailed", { message: error.message });
|
||||
}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
const events = new EventSource("api/events");
|
||||
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
|
||||
events.addEventListener("update", scheduleRefresh);
|
||||
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
|
||||
}
|
||||
|
||||
function setConnectionState(connected, key, values = {}) {
|
||||
state.connection = { connected, key, values };
|
||||
renderConnectionState();
|
||||
}
|
||||
|
||||
function renderRuntimeStatus() {
|
||||
if (!state.status) {
|
||||
elements.statusText.textContent = t("status.connecting");
|
||||
return;
|
||||
}
|
||||
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
|
||||
}
|
||||
|
||||
function renderConnectionState() {
|
||||
const { connected, key, values } = state.connection;
|
||||
elements.connectionLabel.textContent = t(key, values);
|
||||
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
|
||||
}
|
||||
|
||||
function filteredExchanges() {
|
||||
const query = state.search.trim().toLowerCase();
|
||||
const requestId = state.requestId.trim().toLowerCase();
|
||||
const direction = state.sortOrder === "asc" ? 1 : -1;
|
||||
return state.exchanges
|
||||
.filter((item) => {
|
||||
if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false;
|
||||
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
||||
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
||||
if (state.endpoint === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) return false;
|
||||
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
|
||||
if (!query) return true;
|
||||
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
|
||||
if (startedAtDelta !== 0) return startedAtDelta * direction;
|
||||
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBidiMessageFilter() {
|
||||
const visible = state.endpoint === "bidiappend";
|
||||
elements.bidiMessageFilter.hidden = !visible;
|
||||
if (!visible) elements.bidiMessageFilter.open = false;
|
||||
|
||||
const availableKinds = state.exchanges
|
||||
.filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind)
|
||||
.map((item) => item.requestKind);
|
||||
const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right));
|
||||
elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size
|
||||
? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size })
|
||||
: t("filters.allMessageTypes");
|
||||
elements.bidiMessageOptions.innerHTML = [
|
||||
`<label class="multi-select-option all-option"><input type="checkbox" value=""${state.bidiMessageKinds.size === 0 ? " checked" : ""}><span>${escapeHTML(t("filters.allMessageTypes"))}</span></label>`,
|
||||
...kinds.map((kind) => `<label class="multi-select-option"><input type="checkbox" value="${escapeHTML(kind)}"${state.bidiMessageKinds.has(kind) ? " checked" : ""}><span title="${escapeHTML(kind)}">${escapeHTML(kind)}</span></label>`),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const exchanges = filteredExchanges();
|
||||
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
|
||||
const groups = new Map();
|
||||
for (const item of exchanges) {
|
||||
const conversationID = item.conversationId || "";
|
||||
if (!groups.has(conversationID)) groups.set(conversationID, []);
|
||||
groups.get(conversationID).push(item);
|
||||
}
|
||||
elements.requestList.innerHTML = [...groups.entries()]
|
||||
.map(([conversationID, items]) => {
|
||||
const label = conversationID || t("groups.unassigned");
|
||||
const header = `<tr class="conversation-group"><td colspan="9"><span>${escapeHTML(t("groups.conversation"))}</span><code title="${escapeHTML(label)}">${escapeHTML(label)}</code><strong>${items.length}</strong></td></tr>`;
|
||||
const rows = items.map((item) => {
|
||||
const selected = item.id === state.selectedId ? " selected" : "";
|
||||
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
|
||||
const kind = item.responseKind || item.requestKind || "-";
|
||||
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
|
||||
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
|
||||
<td><code>${escapeHTML(item.id)}</code></td>
|
||||
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
|
||||
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
|
||||
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
|
||||
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
|
||||
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
|
||||
<td>${formatBytes(item.responseBytes)}</td>
|
||||
<td>${formatDuration(item.durationMs)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
return header + rows;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTrafficSummary() {
|
||||
const totals = state.exchanges.reduce(
|
||||
(result, item) => {
|
||||
result.up += item.requestBytes || 0;
|
||||
result.down += item.responseBytes || 0;
|
||||
return result;
|
||||
},
|
||||
{ up: 0, down: 0 },
|
||||
);
|
||||
elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!state.selected) {
|
||||
disposeEditor("request");
|
||||
disposeEditor("response");
|
||||
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
|
||||
elements.requestContent.classList.remove("editor-active");
|
||||
elements.responseContent.classList.remove("editor-active");
|
||||
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
|
||||
return;
|
||||
}
|
||||
const item = state.selected;
|
||||
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
|
||||
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
|
||||
renderPayload("request", item.request, state.tabs.request);
|
||||
renderPayload("response", item.response, state.tabs.response);
|
||||
}
|
||||
|
||||
function renderDetailError(error) {
|
||||
disposeEditor("request");
|
||||
disposeEditor("response");
|
||||
elements.requestContent.classList.remove("editor-active");
|
||||
elements.responseContent.classList.remove("editor-active");
|
||||
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
}
|
||||
|
||||
function renderPayload(side, payload, tab) {
|
||||
const container = elements[`${side}Content`];
|
||||
if (!payload) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`);
|
||||
return;
|
||||
}
|
||||
if (tab === "headers") {
|
||||
renderStaticPayload(side, renderHeaders(payload.headers));
|
||||
return;
|
||||
}
|
||||
if (tab === "raw") {
|
||||
if (!payload.rawHex) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noRaw"))}</div>`);
|
||||
return;
|
||||
}
|
||||
renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated));
|
||||
return;
|
||||
}
|
||||
if (tab === "frames") {
|
||||
const document = frameEditorDocument(payload.frames);
|
||||
if (!document) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`);
|
||||
return;
|
||||
}
|
||||
renderEditorPayload(side, document, "json", "");
|
||||
return;
|
||||
}
|
||||
if (payload.decodedJson) {
|
||||
renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`);
|
||||
return;
|
||||
}
|
||||
if (payload.frames?.length) {
|
||||
renderEditorPayload(side, frameEditorDocument(payload.frames), "json", "");
|
||||
return;
|
||||
}
|
||||
if (payload.decodeError) {
|
||||
renderStaticPayload(side, `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`);
|
||||
return;
|
||||
}
|
||||
container.classList.remove("editor-active");
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`);
|
||||
}
|
||||
|
||||
function renderHeaders(headers = []) {
|
||||
const items = Array.isArray(headers) ? headers : [];
|
||||
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
|
||||
return `<table class="headers-table"><tbody>${items
|
||||
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function frameEditorDocument(frames = []) {
|
||||
const items = Array.isArray(frames) ? frames : [];
|
||||
if (!items.length) return "";
|
||||
const normalized = items.map((frame) => {
|
||||
let message = frame.rawHex || null;
|
||||
if (frame.json) {
|
||||
try {
|
||||
message = JSON.parse(frame.json);
|
||||
} catch {
|
||||
message = frame.json;
|
||||
}
|
||||
}
|
||||
return {
|
||||
index: frame.index,
|
||||
kind: frame.kind || frame.messageType || t("notices.unknown"),
|
||||
messageType: frame.messageType || undefined,
|
||||
flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`,
|
||||
length: frame.length,
|
||||
compressed: Boolean(frame.compressed),
|
||||
endStream: Boolean(frame.endStream),
|
||||
requestId: frame.requestId || undefined,
|
||||
error: frame.error || undefined,
|
||||
message,
|
||||
};
|
||||
});
|
||||
return JSON.stringify(normalized, null, 2);
|
||||
}
|
||||
|
||||
function renderStaticPayload(side, markup) {
|
||||
disposeEditor(side);
|
||||
const container = elements[`${side}Content`];
|
||||
container.classList.remove("editor-active");
|
||||
container.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderEditorPayload(side, value, language, notices) {
|
||||
const container = elements[`${side}Content`];
|
||||
const slot = editorSlots[side];
|
||||
slot.value = value;
|
||||
slot.language = language;
|
||||
container.classList.add("editor-active");
|
||||
let host = container.querySelector(".editor-host");
|
||||
if (!host || slot.host !== host) {
|
||||
disposeEditor(side);
|
||||
slot.value = value;
|
||||
slot.language = language;
|
||||
container.innerHTML = `<div class="editor-host"><pre class="editor-fallback">${escapeHTML(value)}</pre></div><div class="editor-notices">${notices}</div>`;
|
||||
host = container.querySelector(".editor-host");
|
||||
void createEditor(side, host, value, language);
|
||||
return;
|
||||
}
|
||||
container.querySelector(".editor-notices").innerHTML = notices;
|
||||
const fallback = host.querySelector(".editor-fallback");
|
||||
if (fallback) fallback.textContent = value;
|
||||
updateEditor(slot, value, language);
|
||||
}
|
||||
|
||||
async function createEditor(side, host, value, language) {
|
||||
const slot = editorSlots[side];
|
||||
const token = ++slot.token;
|
||||
slot.host = host;
|
||||
try {
|
||||
const monaco = await monacoReady;
|
||||
if (token !== slot.token || !host.isConnected) return;
|
||||
host.textContent = "";
|
||||
const model = monaco.editor.createModel(slot.value || value, slot.language || language);
|
||||
const editor = monaco.editor.create(host, {
|
||||
model,
|
||||
theme: "vs-dark",
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
automaticLayout: true,
|
||||
fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
minimap: { enabled: false },
|
||||
glyphMargin: false,
|
||||
folding: true,
|
||||
lineNumbersMinChars: 3,
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
renderLineHighlight: "none",
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: "off",
|
||||
padding: { top: 8, bottom: 16 },
|
||||
stickyScroll: { enabled: false },
|
||||
contextmenu: true,
|
||||
});
|
||||
slot.editor = editor;
|
||||
slot.model = model;
|
||||
slot.host = host;
|
||||
} catch {
|
||||
// Monaco 初始化失败时保留文本回退视图。
|
||||
}
|
||||
}
|
||||
|
||||
function updateEditor(slot, value, language) {
|
||||
if (!slot.editor || !slot.model) return;
|
||||
const monaco = window.monaco;
|
||||
if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language);
|
||||
if (slot.model.getValue() === value) return;
|
||||
const viewState = slot.editor.saveViewState();
|
||||
slot.model.setValue(value);
|
||||
if (viewState) slot.editor.restoreViewState(viewState);
|
||||
}
|
||||
|
||||
function disposeEditor(side) {
|
||||
const slot = editorSlots[side];
|
||||
slot.token += 1;
|
||||
slot.editor?.dispose();
|
||||
slot.model?.dispose();
|
||||
slot.editor = null;
|
||||
slot.model = null;
|
||||
slot.host = null;
|
||||
slot.value = "";
|
||||
slot.language = "plaintext";
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const amdRequire = window.require;
|
||||
if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") {
|
||||
reject(new Error("Monaco loader is unavailable"));
|
||||
return;
|
||||
}
|
||||
amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } });
|
||||
amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
|
||||
});
|
||||
}
|
||||
|
||||
function applyLocale() {
|
||||
translateDocument();
|
||||
elements.localeSelect.value = getLocale();
|
||||
renderRuntimeStatus();
|
||||
renderConnectionState();
|
||||
renderPauseState(state, elements);
|
||||
renderConversationOptions();
|
||||
renderBidiMessageFilter();
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
renderDetail();
|
||||
}
|
||||
bindEvents({
|
||||
state,
|
||||
elements,
|
||||
fetchJSON,
|
||||
refreshList,
|
||||
refreshDetail,
|
||||
renderList,
|
||||
renderDetail,
|
||||
renderBidiMessageFilter,
|
||||
setConnectionState,
|
||||
applyLocale,
|
||||
});
|
||||
async function bootstrap() {
|
||||
applyLocale();
|
||||
renderDetail();
|
||||
try {
|
||||
await Promise.all([loadStatus(), refreshList()]);
|
||||
connectEvents();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.connectFailed", { message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,159 @@
|
||||
// app_events.js 绑定调试器筛选、详情、暂停和布局交互事件。
|
||||
import { t } from "./i18n.js";
|
||||
import { currentCopyText } from "./view_helpers.js";
|
||||
|
||||
// renderPauseState 更新暂停按钮的文本和可访问性属性。
|
||||
export function renderPauseState(state, elements) {
|
||||
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
|
||||
const actionKey = state.paused ? "actions.resume" : "actions.pause";
|
||||
elements.pauseButton.title = t(actionKey);
|
||||
elements.pauseButton.setAttribute("aria-label", t(actionKey));
|
||||
}
|
||||
|
||||
// bindEvents 绑定调试器页面的筛选、详情、暂停和布局交互。
|
||||
export function bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale }) {
|
||||
elements.requestList.addEventListener("click", async (event) => {
|
||||
const row = event.target.closest("tr[data-id]");
|
||||
if (!row) return;
|
||||
state.selectedId = row.dataset.id;
|
||||
state.selected = null;
|
||||
renderList();
|
||||
renderDetail();
|
||||
await refreshDetail(state.selectedId);
|
||||
});
|
||||
|
||||
elements.searchInput.addEventListener("input", (event) => {
|
||||
state.search = event.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.requestIdInput.addEventListener("input", (event) => {
|
||||
state.requestId = event.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.conversationSelect.addEventListener("change", async (event) => {
|
||||
state.conversationId = event.target.value;
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
await refreshList();
|
||||
renderDetail();
|
||||
});
|
||||
|
||||
elements.endpointFilter.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-value]");
|
||||
if (!button) return;
|
||||
state.endpoint = button.dataset.value;
|
||||
for (const item of elements.endpointFilter.querySelectorAll("button")) {
|
||||
item.classList.toggle("active", item === button);
|
||||
}
|
||||
renderBidiMessageFilter();
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.bidiMessageOptions.addEventListener("change", (event) => {
|
||||
const checkbox = event.target.closest('input[type="checkbox"]');
|
||||
if (!checkbox) return;
|
||||
if (!checkbox.value) {
|
||||
state.bidiMessageKinds.clear();
|
||||
} else if (checkbox.checked) {
|
||||
state.bidiMessageKinds.add(checkbox.value);
|
||||
} else {
|
||||
state.bidiMessageKinds.delete(checkbox.value);
|
||||
}
|
||||
renderBidiMessageFilter();
|
||||
elements.bidiMessageFilter.open = true;
|
||||
renderList();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!elements.bidiMessageFilter.contains(event.target)) elements.bidiMessageFilter.open = false;
|
||||
});
|
||||
|
||||
elements.sortOrder.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-value]");
|
||||
if (!button) return;
|
||||
state.sortOrder = button.dataset.value;
|
||||
for (const item of elements.sortOrder.querySelectorAll("button")) {
|
||||
item.classList.toggle("active", item === button);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".payload-panel").forEach((panel) => {
|
||||
panel.querySelector(".tabs").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-tab]");
|
||||
if (!button) return;
|
||||
const side = panel.dataset.side;
|
||||
state.tabs[side] = button.dataset.tab;
|
||||
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
renderDetail();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-copy-side]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = currentCopyText(button.dataset.copySide, state);
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = t("actions.copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = t("actions.copy");
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
|
||||
elements.pauseButton.addEventListener("click", async () => {
|
||||
state.paused = !state.paused;
|
||||
elements.pauseButton.classList.toggle("active", state.paused);
|
||||
renderPauseState(state, elements);
|
||||
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
|
||||
if (!state.paused && state.pendingRefresh) {
|
||||
state.pendingRefresh = false;
|
||||
await refreshList();
|
||||
}
|
||||
});
|
||||
|
||||
elements.localeSelect.addEventListener("change", (event) => {
|
||||
setLocale(event.target.value);
|
||||
applyLocale();
|
||||
});
|
||||
|
||||
elements.showOptionsCheckbox.addEventListener("change", (event) => {
|
||||
state.showOptions = event.target.checked;
|
||||
if (!state.showOptions && String(state.selected?.method || "").toUpperCase() === "OPTIONS") {
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
renderDetail();
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.clearButton.addEventListener("click", async () => {
|
||||
await fetchJSON("api/exchanges", { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
state.conversationId = "";
|
||||
await refreshList();
|
||||
renderDetail();
|
||||
});
|
||||
|
||||
let draggingSplitter = false;
|
||||
elements.splitter.addEventListener("pointerdown", (event) => {
|
||||
draggingSplitter = true;
|
||||
elements.splitter.classList.add("dragging");
|
||||
elements.splitter.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
elements.splitter.addEventListener("pointermove", (event) => {
|
||||
if (!draggingSplitter) return;
|
||||
const bounds = elements.workspace.getBoundingClientRect();
|
||||
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
|
||||
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
|
||||
});
|
||||
|
||||
elements.splitter.addEventListener("pointerup", () => {
|
||||
draggingSplitter = false;
|
||||
elements.splitter.classList.remove("dragging");
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// i18n.js 提供协议调试器中英文消息和运行时语言切换。
|
||||
const SOURCE_LOCALE = "zh-CN";
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
|
||||
@@ -7,10 +8,8 @@ const messages = {
|
||||
"zh-CN": {
|
||||
"app.title": "Cursor 协议调试器",
|
||||
"status.connecting": "正在连接",
|
||||
"status.running": "代理运行中",
|
||||
"status.stopped": "代理已停止",
|
||||
"actions.downloadCA": "下载代理 CA 证书",
|
||||
"actions.caCertificate": "CA 证书",
|
||||
"status.running": "服务运行中",
|
||||
"status.stopped": "服务已停止",
|
||||
"actions.pause": "暂停界面更新",
|
||||
"actions.resume": "继续界面更新",
|
||||
"actions.clear": "清空",
|
||||
@@ -20,13 +19,19 @@ const messages = {
|
||||
"filters.region": "请求过滤器",
|
||||
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
|
||||
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||
"filters.conversation": "按 Conversation ID 查询",
|
||||
"filters.allConversations": "全部会话",
|
||||
"filters.endpoint": "接口过滤",
|
||||
"filters.all": "全部",
|
||||
"filters.fork": "Fork",
|
||||
"filters.allMessageTypes": "全部消息类型",
|
||||
"filters.selectedMessageTypes": "已选 {count} 种消息",
|
||||
"filters.showOptions": "显示 OPTIONS",
|
||||
"filters.sort": "排序方向",
|
||||
"filters.ascending": "正序",
|
||||
"filters.descending": "倒序",
|
||||
"count.requests": "{count} 条",
|
||||
"groups.conversation": "会话",
|
||||
"groups.unassigned": "未关联会话",
|
||||
"table.url": "网址",
|
||||
"table.message": "消息",
|
||||
"table.method": "方法",
|
||||
@@ -67,10 +72,8 @@ const messages = {
|
||||
"en-US": {
|
||||
"app.title": "Cursor Protocol Debugger",
|
||||
"status.connecting": "Connecting",
|
||||
"status.running": "Proxy running",
|
||||
"status.stopped": "Proxy stopped",
|
||||
"actions.downloadCA": "Download proxy CA certificate",
|
||||
"actions.caCertificate": "CA Certificate",
|
||||
"status.running": "Service running",
|
||||
"status.stopped": "Service stopped",
|
||||
"actions.pause": "Pause UI updates",
|
||||
"actions.resume": "Resume UI updates",
|
||||
"actions.clear": "Clear",
|
||||
@@ -80,13 +83,19 @@ const messages = {
|
||||
"filters.region": "Request filters",
|
||||
"filters.urlPlaceholder": "Filter by URL, message type, or status",
|
||||
"filters.requestIdPlaceholder": "Filter by Request ID",
|
||||
"filters.conversation": "Query by Conversation ID",
|
||||
"filters.allConversations": "All conversations",
|
||||
"filters.endpoint": "Endpoint filter",
|
||||
"filters.all": "All",
|
||||
"filters.fork": "Fork",
|
||||
"filters.allMessageTypes": "All message types",
|
||||
"filters.selectedMessageTypes": "{count} message types",
|
||||
"filters.showOptions": "Show OPTIONS",
|
||||
"filters.sort": "Sort order",
|
||||
"filters.ascending": "Oldest first",
|
||||
"filters.descending": "Newest first",
|
||||
"count.requests": "{count} requests",
|
||||
"groups.conversation": "Conversation",
|
||||
"groups.unassigned": "Unassigned",
|
||||
"table.url": "URL",
|
||||
"table.message": "Message",
|
||||
"table.method": "Method",
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Cursor 协议调试器</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app-shell">
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="runtime-status" aria-live="polite">
|
||||
<span id="status-dot" class="status-dot"></span>
|
||||
<span id="status-text" data-i18n="status.connecting">正在连接</span>
|
||||
<code id="proxy-address"></code>
|
||||
<code id="service-address"></code>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="locale-picker">
|
||||
@@ -27,7 +27,6 @@
|
||||
<option value="en-US">EN</option>
|
||||
</select>
|
||||
</label>
|
||||
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
|
||||
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause">Ⅱ</button>
|
||||
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
|
||||
</div>
|
||||
@@ -42,12 +41,25 @@
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
|
||||
</div>
|
||||
<label class="conversation-filter">
|
||||
<span class="visually-hidden" data-i18n="filters.conversation">会话</span>
|
||||
<select id="conversation-select" aria-label="按 Conversation ID 查询" data-i18n-aria-label="filters.conversation">
|
||||
<option value="" data-i18n="filters.allConversations">全部会话</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
|
||||
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
|
||||
<button type="button" data-value="runsse">RunSSE</button>
|
||||
<button type="button" data-value="bidiappend">BidiAppend</button>
|
||||
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
|
||||
</div>
|
||||
<details id="bidi-message-filter" class="multi-select-filter" hidden>
|
||||
<summary data-i18n="filters.allMessageTypes">全部消息类型</summary>
|
||||
<div id="bidi-message-options" class="multi-select-menu"></div>
|
||||
</details>
|
||||
<label class="checkbox-control">
|
||||
<input id="show-options-checkbox" type="checkbox" />
|
||||
<span data-i18n="filters.showOptions">显示 OPTIONS</span>
|
||||
</label>
|
||||
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
|
||||
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
|
||||
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
|
||||
@@ -103,8 +115,8 @@
|
||||
<strong data-i18n="panel.response">响应</strong>
|
||||
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
|
||||
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
|
||||
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
|
||||
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames">帧</button>
|
||||
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
|
||||
<button type="button" data-tab="frames" data-i18n="tabs.frames">帧</button>
|
||||
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
|
||||
</nav>
|
||||
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
|
||||
@@ -118,9 +130,10 @@
|
||||
<footer class="statusbar">
|
||||
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
|
||||
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
|
||||
<span id="target-host"></span>
|
||||
<span id="upstream-url"></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs/loader.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
/* styles.css 组合调试器的基础、控件、详情和响应式样式。 */
|
||||
@import url("./styles_base.css");
|
||||
@import url("./styles_controls.css");
|
||||
@import url("./styles_detail.css");
|
||||
@import url("./styles_responsive.css");
|
||||
@@ -0,0 +1,445 @@
|
||||
/* styles.css 定义协议调试器的暗色布局、组件和响应式样式。 */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #171818;
|
||||
color: #dedfdd;
|
||||
font-synthesis: none;
|
||||
--surface-0: #171818;
|
||||
--surface-1: #1d1f1f;
|
||||
--surface-2: #242626;
|
||||
--surface-3: #2c2f2f;
|
||||
--border: #343737;
|
||||
--border-strong: #454949;
|
||||
--muted: #8d9390;
|
||||
--text: #dedfdd;
|
||||
--accent: #4ea58b;
|
||||
--accent-soft: #25473d;
|
||||
--cyan: #55a8ba;
|
||||
--orange: #c88762;
|
||||
--danger: #c56d65;
|
||||
--selection: #245b73;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
a {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
|
||||
min-width: 760px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.filterbar,
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-color: var(--border);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.runtime-status,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--surface-1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 240px;
|
||||
color: #bec3c0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-status code {
|
||||
overflow: hidden;
|
||||
max-width: 260px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #7d8380;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #42bd79;
|
||||
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.locale-picker {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.locale-picker select {
|
||||
width: 58px;
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
padding: 0 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.button,
|
||||
.icon-button,
|
||||
.copy-button {
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.icon-button:hover,
|
||||
.copy-button:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
border-color: #744640;
|
||||
color: #f2b0aa;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 31px;
|
||||
padding: 0;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-button.active {
|
||||
border-color: var(--orange);
|
||||
color: #f1bb98;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 7px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
max-width: 640px;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: #191b1b;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-box > span {
|
||||
padding-left: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0 9px;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #6f7572;
|
||||
}
|
||||
|
||||
.url-filter {
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.request-id-filter {
|
||||
flex: 0 1 320px;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.conversation-filter {
|
||||
flex: 0 1 300px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.conversation-filter select {
|
||||
width: 100%;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0 28px 0 9px;
|
||||
background: #191b1b;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
display: flex;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segmented-control button {
|
||||
min-width: 62px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0 10px;
|
||||
background: #1b1d1d;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.segmented-control button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.segmented-control button.active {
|
||||
background: var(--accent-soft);
|
||||
color: #bce8d9;
|
||||
}
|
||||
|
||||
.sort-control button {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.checkbox-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0 9px;
|
||||
background: #1b1d1d;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-control:has(input:checked) {
|
||||
border-color: #376858;
|
||||
background: var(--accent-soft);
|
||||
color: #bce8d9;
|
||||
}
|
||||
|
||||
.checkbox-control input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.multi-select-filter {
|
||||
position: relative;
|
||||
flex: 0 0 170px;
|
||||
height: 31px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.multi-select-filter[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multi-select-filter summary {
|
||||
overflow: hidden;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 7px 28px 0 9px;
|
||||
background: #191b1b;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-filter summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multi-select-filter summary::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
content: "";
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
.multi-select-filter[open] summary {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.multi-select-menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 35px;
|
||||
right: 0;
|
||||
overflow: auto;
|
||||
width: 260px;
|
||||
max-height: 320px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
padding: 4px;
|
||||
background: var(--surface-2);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 34%);
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 29px;
|
||||
border-radius: 3px;
|
||||
padding: 0 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.multi-select-option input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.multi-select-option span {
|
||||
overflow: hidden;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-option.all-option {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* styles_controls.css 定义调试器筛选栏、请求列表和基础交互控件。 */
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-list-pane {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #181a1a;
|
||||
}
|
||||
|
||||
.request-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.request-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #202222;
|
||||
}
|
||||
|
||||
.request-table th,
|
||||
.request-table td {
|
||||
height: 30px;
|
||||
border-right: 1px solid #2c2f2f;
|
||||
border-bottom: 1px solid #292c2c;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-table th {
|
||||
color: #9da29f;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.request-table tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.request-table tbody tr:hover {
|
||||
background: #222525;
|
||||
}
|
||||
|
||||
.request-table tbody tr.selected {
|
||||
background: var(--selection);
|
||||
color: #f3f8f8;
|
||||
}
|
||||
|
||||
.request-table tbody tr.conversation-group,
|
||||
.request-table tbody tr.conversation-group:hover {
|
||||
cursor: default;
|
||||
background: #202323;
|
||||
}
|
||||
|
||||
.request-table tbody tr.conversation-group td {
|
||||
height: 28px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-group span {
|
||||
margin-right: 8px;
|
||||
color: #9ba19e;
|
||||
}
|
||||
|
||||
.conversation-group code {
|
||||
color: #73becb;
|
||||
}
|
||||
|
||||
.conversation-group strong {
|
||||
margin-left: 8px;
|
||||
color: #78817d;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.request-table code {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.status-column {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.index-column {
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.request-id-column {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.kind-column {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.method-column {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.code-column {
|
||||
width: 66px;
|
||||
}
|
||||
|
||||
.size-column {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.row-state {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border-radius: 50%;
|
||||
background: #7c8380;
|
||||
}
|
||||
|
||||
.row-state.streaming {
|
||||
background: #45ba77;
|
||||
}
|
||||
|
||||
.row-state.completed {
|
||||
background: var(--cyan);
|
||||
}
|
||||
|
||||
.row-state.error {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.method-text {
|
||||
color: #61b9df;
|
||||
font-family: var(--mono);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: #68c991;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: #e18b83;
|
||||
}
|
||||
|
||||
.kind-text {
|
||||
color: #d3a17f;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.request-id-text {
|
||||
color: #8bc2cc;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 34px 0 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #686e6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.horizontal-splitter {
|
||||
cursor: row-resize;
|
||||
background: #343737;
|
||||
}
|
||||
|
||||
.horizontal-splitter:hover,
|
||||
.horizontal-splitter.dragging {
|
||||
background: var(--cyan);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/* styles_detail.css 定义请求详情、载荷面板和状态提示布局。 */
|
||||
.detail-pane {
|
||||
display: grid;
|
||||
grid-template-rows: 38px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #1b1d1d;
|
||||
}
|
||||
|
||||
.selection-summary code {
|
||||
overflow: hidden;
|
||||
color: #aeb4b1;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge,
|
||||
.status-badge,
|
||||
.frame-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 7px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
border-color: #34667a;
|
||||
color: #74c8e8;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
border-color: #3f7157;
|
||||
color: #83d5a5;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 36px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.payload-panel:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #202222;
|
||||
}
|
||||
|
||||
.panel-header > strong {
|
||||
padding: 0 10px;
|
||||
color: #c9cdca;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
border: 0;
|
||||
padding: 0 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: #d7dad8;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: #71c7e2;
|
||||
}
|
||||
|
||||
.tabs button.active::after {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
height: 2px;
|
||||
background: var(--cyan);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
margin-right: 7px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #181a1a;
|
||||
}
|
||||
|
||||
.panel-content.editor-active {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-host {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.editor-fallback {
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 14px 24px;
|
||||
overflow: auto;
|
||||
color: #d4d4d4;
|
||||
font: 12px/1.58 var(--mono);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.editor-notices:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-view,
|
||||
.hex-view {
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 14px 30px;
|
||||
color: #ccd1ce;
|
||||
font: 11px/1.55 var(--mono);
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.hex-view {
|
||||
color: #b6c2bd;
|
||||
}
|
||||
|
||||
.headers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: 11px/1.4 var(--mono);
|
||||
}
|
||||
|
||||
.headers-table th,
|
||||
.headers-table td {
|
||||
border-bottom: 1px solid #292c2c;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.headers-table th {
|
||||
width: 38%;
|
||||
color: #62b3cd;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.headers-table td {
|
||||
color: #c5c9c6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.frame-list {
|
||||
min-width: 480px;
|
||||
}
|
||||
|
||||
.frame-item {
|
||||
border-bottom: 1px solid #292c2c;
|
||||
}
|
||||
|
||||
.frame-item summary {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #c5cac7;
|
||||
cursor: pointer;
|
||||
font: 11px var(--mono);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.frame-item summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.frame-item summary:hover {
|
||||
background: #222525;
|
||||
}
|
||||
|
||||
.frame-item[open] summary {
|
||||
background: #242727;
|
||||
}
|
||||
|
||||
.frame-index {
|
||||
color: #747b77;
|
||||
}
|
||||
|
||||
.frame-kind {
|
||||
overflow: hidden;
|
||||
color: #dfaa85;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frame-size,
|
||||
.frame-flags {
|
||||
color: #7faeb7;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.frame-error {
|
||||
margin: 10px 14px;
|
||||
color: #ec968e;
|
||||
font: 11px/1.5 var(--mono);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 14px;
|
||||
color: #7d8581;
|
||||
font: 12px/1.6 var(--mono);
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
color: #df8b83;
|
||||
}
|
||||
|
||||
.truncated-notice {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid #674f3f;
|
||||
background: #3d3028;
|
||||
color: #e5b28e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: #848b87;
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.statusbar span:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/* styles_responsive.css 定义调试器在窄屏下的响应式布局。 */
|
||||
@media (max-width: 920px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.url-filter,
|
||||
.request-id-filter {
|
||||
flex: 1 1 300px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.runtime-status code,
|
||||
.kind-column,
|
||||
.request-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
min-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 82px auto minmax(0, 1fr) 26px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
order: 3;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime-status code {
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-actions .button {
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.url-filter,
|
||||
.request-id-filter {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
order: 5;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#endpoint-filter {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sort-control {
|
||||
order: 4;
|
||||
flex: 0 0 104px;
|
||||
}
|
||||
|
||||
.segmented-control button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
|
||||
}
|
||||
|
||||
.request-table th,
|
||||
.request-table td {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.request-table .index-column,
|
||||
.request-table th:nth-child(2),
|
||||
.request-table td:nth-child(2),
|
||||
.size-column,
|
||||
.request-table th:nth-child(8),
|
||||
.request-table td:nth-child(8),
|
||||
.time-column,
|
||||
.request-table th:nth-child(9),
|
||||
.request-table td:nth-child(9) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.request-id-column {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.method-column {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.code-column {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.panel-header > strong {
|
||||
width: 72px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
min-width: 42px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 42px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.frame-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frame-item summary {
|
||||
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#upstream-url {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// view_helpers.js 提供调试器界面使用的格式化、转义和复制文本辅助函数。
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
// renderDecodeError 将解码错误转换为安全的提示片段。
|
||||
export function renderDecodeError(error) {
|
||||
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
|
||||
}
|
||||
|
||||
// renderTruncated 生成正文被截断时的提示片段。
|
||||
export function renderTruncated(truncated) {
|
||||
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
|
||||
}
|
||||
|
||||
// formatState 将捕获状态转换为当前语言的展示文本。
|
||||
export function formatState(value) {
|
||||
const key = {
|
||||
pending: "state.pending",
|
||||
streaming: "state.streaming",
|
||||
completed: "state.completed",
|
||||
error: "state.error",
|
||||
}[value];
|
||||
return key ? t(key) : value || "-";
|
||||
}
|
||||
|
||||
// currentCopyText 根据当前标签页提取可复制的载荷文本。
|
||||
export function currentCopyText(side, state) {
|
||||
const payload = state.selected?.[side];
|
||||
if (!payload) return "";
|
||||
const tab = state.tabs[side];
|
||||
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
|
||||
if (tab === "raw") return payload.rawHex || "";
|
||||
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
|
||||
return payload.decodedJson || "";
|
||||
}
|
||||
|
||||
// formatHex 将十六进制载荷按行格式化为调试视图。
|
||||
export function formatHex(value) {
|
||||
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
|
||||
const lines = [];
|
||||
for (let index = 0; index < hex.length; index += 32) {
|
||||
const chunk = hex.slice(index, index + 32);
|
||||
const bytes = chunk.match(/.{1,2}/g) || [];
|
||||
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// formatBytes 将字节数格式化为人类可读的单位。
|
||||
export function formatBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// formatDuration 将毫秒耗时格式化为人类可读的单位。
|
||||
export function formatDuration(value) {
|
||||
const milliseconds = Number(value || 0);
|
||||
if (milliseconds < 1000) return `${milliseconds} ms`;
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
// escapeHTML 转义用户或网络输入,避免插入界面时形成 HTML。
|
||||
export function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# cursor-proto
|
||||
|
||||
`cursor-proto` 从已安装 Cursor 的 JavaScript bundle 中提取 Protobuf 定义。
|
||||
|
||||
## 目录
|
||||
|
||||
- `extractor/`:Go 提取器。
|
||||
- `scripts/extract.sh`:扫描 Cursor 安装目录并安全更新输出。
|
||||
- `proto/`:提取结果,是项目内唯一的 Proto 输出目录;由脚本重新生成,不提交到 Git。
|
||||
- `scripts/generate.sh`:根据提取结果生成可导入的 Go 消息包。
|
||||
- `gen/`:供其他 Go module 使用的 Go 消息包;由脚本重新生成,不提交到 Git。
|
||||
|
||||
## 使用
|
||||
|
||||
默认从 `/Applications/Cursor.app` 提取:
|
||||
|
||||
```bash
|
||||
./scripts/extract.sh
|
||||
```
|
||||
|
||||
也可以指定 Cursor 应用、bundle 文件和输出目录:
|
||||
|
||||
```bash
|
||||
./scripts/extract.sh /path/to/Cursor.app
|
||||
./scripts/extract.sh /path/to/workbench.desktop.main.js /path/to/output
|
||||
```
|
||||
|
||||
直接运行 Go 提取器时,可重复传入多个 bundle:
|
||||
|
||||
```bash
|
||||
go run ./extractor \
|
||||
-input /path/to/workbench.desktop.main.js \
|
||||
-input /path/to/extensionHostProcess.js \
|
||||
-output ./proto \
|
||||
-strict
|
||||
```
|
||||
|
||||
提取完成后重新生成 Go 消息包:
|
||||
|
||||
```bash
|
||||
./scripts/generate.sh
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
|
||||
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
|
||||
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse shorthand T: %v", err)
|
||||
}
|
||||
if field.T != "T" {
|
||||
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
|
||||
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
|
||||
const bundle = `
|
||||
1:(e,t,n)=>{
|
||||
n.d(t,{KS:()=>T,_B:()=>r});
|
||||
var r;
|
||||
class T {}
|
||||
T.typeName="agent.v1.AgentClientMessage";
|
||||
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
|
||||
},
|
||||
2:(e,t,n)=>{
|
||||
var r=n(1);
|
||||
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := []Message{{
|
||||
TypeName: "agent.v1.AgentClientMessage",
|
||||
VarName: "T",
|
||||
InternalName: "T",
|
||||
Package: "agent.v1",
|
||||
Pos: 35,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, 35),
|
||||
}}
|
||||
enums := []Enum{{
|
||||
TypeName: "agent.v1.DiagnosticSeverity",
|
||||
VarName: "r",
|
||||
Package: "agent.v1",
|
||||
Pos: 100,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, 100),
|
||||
}}
|
||||
|
||||
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
|
||||
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
|
||||
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
|
||||
if !ok {
|
||||
t.Fatal("expected webpack export alias to resolve")
|
||||
}
|
||||
if typeName != "agent.v1.AgentClientMessage" {
|
||||
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
|
||||
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
|
||||
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
|
||||
"nt": {
|
||||
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
|
||||
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
|
||||
},
|
||||
}}
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
|
||||
if !ok {
|
||||
t.Fatal("expected cross-package message type to resolve")
|
||||
}
|
||||
if typeName != "git_forge.v1.GetTagResponse" {
|
||||
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
|
||||
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
|
||||
const bundle = `
|
||||
42:(e,t,n)=>{
|
||||
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
|
||||
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
|
||||
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
|
||||
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
|
||||
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := extractMessages(bundle, moduleStarts)
|
||||
enums := extractEnums(bundle, moduleStarts)
|
||||
services := extractServices(bundle, moduleStarts)
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("extracted %d messages, want 3", len(messages))
|
||||
}
|
||||
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
|
||||
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
|
||||
}
|
||||
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
|
||||
t.Fatalf("unexpected enums: %#v", enums)
|
||||
}
|
||||
if len(services) != 1 || len(services[0].Methods) != 2 {
|
||||
t.Fatalf("unexpected services: %#v", services)
|
||||
}
|
||||
|
||||
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
|
||||
method := services[0].Methods[0]
|
||||
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
|
||||
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
|
||||
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
|
||||
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
|
||||
}
|
||||
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
|
||||
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
|
||||
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
|
||||
const bundle = `
|
||||
1:(e,t,n)=>{
|
||||
var Timestamp=class TimestampMessage extends Base{};
|
||||
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
|
||||
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := extractMessages(bundle, moduleStarts)
|
||||
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
|
||||
if !ok || typeName != "google.protobuf.Timestamp" {
|
||||
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
|
||||
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
|
||||
const bundle = `
|
||||
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
|
||||
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
|
||||
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
|
||||
var Service={typeName:"aiserver.v1.TestService",methods:{}};
|
||||
`
|
||||
messages := []Message{{TypeName: "aiserver.v1.Request"}}
|
||||
services := []Service{{TypeName: "aiserver.v1.TestService"}}
|
||||
|
||||
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
|
||||
if declared != 3 || extracted != 2 {
|
||||
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
|
||||
t.Fatalf("unexpected missing declarations: %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
|
||||
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
|
||||
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
|
||||
services := extractServices(bundle, nil)
|
||||
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
|
||||
t.Fatalf("unexpected services: %#v", services)
|
||||
}
|
||||
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
|
||||
t.Fatalf("unexpected methods: %#v", services[0].Methods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
|
||||
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
|
||||
primary := Message{
|
||||
TypeName: "aiserver.v1.Shared",
|
||||
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
|
||||
}
|
||||
supplemental := Message{
|
||||
TypeName: "aiserver.v1.Shared",
|
||||
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
|
||||
}
|
||||
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
|
||||
|
||||
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged %d messages, want 2", len(merged))
|
||||
}
|
||||
if merged[0].Fields[0].Name != "primary" {
|
||||
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
|
||||
}
|
||||
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
|
||||
t.Fatalf("supplemental-only type missing: %#v", merged)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// generateProtos 按协议包聚合声明并生成对应文件。
|
||||
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
|
||||
// 按协议包聚合声明。
|
||||
packages := make(map[string]struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
})
|
||||
|
||||
for _, msg := range messages {
|
||||
pkg := packages[msg.Package]
|
||||
pkg.messages = append(pkg.messages, msg)
|
||||
packages[msg.Package] = pkg
|
||||
}
|
||||
|
||||
for _, enum := range enums {
|
||||
pkg := packages[enum.Package]
|
||||
pkg.enums = append(pkg.enums, enum)
|
||||
packages[enum.Package] = pkg
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
pkg := packages[svc.Package]
|
||||
pkg.services = append(pkg.services, svc)
|
||||
packages[svc.Package] = pkg
|
||||
}
|
||||
|
||||
// 建立跨包复制使用的全局类型索引。
|
||||
allMessages := make(map[string]*Message)
|
||||
allEnums := make(map[string]*Enum)
|
||||
|
||||
for pkgName, pkg := range packages {
|
||||
if isGooglePkg(pkgName) {
|
||||
continue
|
||||
}
|
||||
for i := range pkg.messages {
|
||||
msg := &pkg.messages[i]
|
||||
allMessages[msg.TypeName] = msg
|
||||
}
|
||||
for i := range pkg.enums {
|
||||
enum := &pkg.enums[i]
|
||||
allEnums[enum.TypeName] = enum
|
||||
}
|
||||
}
|
||||
|
||||
// 每轮生成前重置已复制类型索引。
|
||||
copiedTypes = make(map[string]map[string]string)
|
||||
|
||||
for pkgName, pkg := range packages {
|
||||
// Google 标准包直接使用官方协议文件。
|
||||
if isGooglePkg(pkgName) {
|
||||
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
|
||||
continue
|
||||
}
|
||||
|
||||
// 把当前包引用的外部类型复制到本地。
|
||||
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
|
||||
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
|
||||
func copyAllExternalTypes(pkgName string, pkg struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
} {
|
||||
if copiedTypes[pkgName] == nil {
|
||||
copiedTypes[pkgName] = make(map[string]string)
|
||||
}
|
||||
|
||||
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
|
||||
localTypes := make(map[string]bool)
|
||||
for _, msg := range pkg.messages {
|
||||
localTypes[msg.ShortName] = true
|
||||
// 空来源名表示该类型原本就在当前包。
|
||||
if copiedTypes[pkgName][msg.ShortName] == "" {
|
||||
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
|
||||
}
|
||||
}
|
||||
for _, enum := range pkg.enums {
|
||||
localTypes[enum.ShortName] = true
|
||||
if copiedTypes[pkgName][enum.ShortName] == "" {
|
||||
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
|
||||
}
|
||||
}
|
||||
|
||||
// 结果先保留当前包原始声明。
|
||||
result := struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
}{
|
||||
messages: append([]Message{}, pkg.messages...),
|
||||
enums: append([]Enum{}, pkg.enums...),
|
||||
services: pkg.services,
|
||||
}
|
||||
|
||||
totalCopied := 0
|
||||
|
||||
// 持续迭代,直到不再发现新的外部依赖。
|
||||
for round := 1; ; round++ {
|
||||
// 收集当前消息中的外部类型引用。
|
||||
neededTypes := make(map[string]bool)
|
||||
|
||||
for _, msg := range result.messages {
|
||||
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||
for _, f := range msg.Fields {
|
||||
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
||||
}
|
||||
}
|
||||
for _, svc := range result.services {
|
||||
for _, m := range svc.Methods {
|
||||
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
|
||||
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制本轮新增依赖类型。
|
||||
copiedThisRound := 0
|
||||
for typeName := range neededTypes {
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == pkgName || isGooglePkg(refPkg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 已存在于本地时无需重复复制。
|
||||
if localTypes[shortName] {
|
||||
continue
|
||||
}
|
||||
|
||||
// 复制消息声明。
|
||||
if msg, ok := allMessages[typeName]; ok {
|
||||
msgCopy := *msg
|
||||
msgCopy.Package = pkgName
|
||||
// 保留原始完整类型名,用于生成来源注释。
|
||||
result.messages = append(result.messages, msgCopy)
|
||||
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
|
||||
localTypes[shortName] = true
|
||||
copiedThisRound++
|
||||
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
|
||||
} else if enum, ok := allEnums[typeName]; ok {
|
||||
// 复制枚举声明。
|
||||
enumCopy := *enum
|
||||
enumCopy.Package = pkgName
|
||||
result.enums = append(result.enums, enumCopy)
|
||||
copiedTypes[pkgName][shortName] = typeName
|
||||
localTypes[shortName] = true
|
||||
copiedThisRound++
|
||||
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
|
||||
} else {
|
||||
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
|
||||
copiedTypes[pkgName][shortName] = typeName
|
||||
localTypes[shortName] = true
|
||||
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
|
||||
}
|
||||
}
|
||||
|
||||
totalCopied += copiedThisRound
|
||||
|
||||
if copiedThisRound == 0 {
|
||||
break // 没有新增依赖时结束迭代。
|
||||
}
|
||||
|
||||
if round > 20 {
|
||||
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if totalCopied > 0 {
|
||||
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
|
||||
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||
neededTypes map[string]bool, localTypes map[string]bool) {
|
||||
|
||||
type refWithKind struct {
|
||||
ref string
|
||||
kind string
|
||||
}
|
||||
|
||||
var refs []refWithKind
|
||||
if f.Kind == "message" || f.Kind == "enum" {
|
||||
if v, ok := f.T.(string); ok {
|
||||
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
|
||||
}
|
||||
}
|
||||
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
|
||||
if v, ok := f.MapValueT.(string); ok {
|
||||
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range refs {
|
||||
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 已在当前包中的类型无需收集。
|
||||
if localTypes[shortName] {
|
||||
continue
|
||||
}
|
||||
|
||||
neededTypes[typeName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
|
||||
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||
neededTypes map[string]bool, localTypes map[string]bool) {
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
|
||||
return
|
||||
}
|
||||
|
||||
if localTypes[shortName] {
|
||||
return
|
||||
}
|
||||
|
||||
neededTypes[typeName] = true
|
||||
}
|
||||
|
||||
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
|
||||
var copiedTypes = make(map[string]map[string]string)
|
||||
|
||||
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
|
||||
type TypeNode struct {
|
||||
// Name 是当前嵌套层级的类型名。
|
||||
Name string
|
||||
// Message 保存当前节点的消息声明。
|
||||
Message *Message
|
||||
// Enum 保存当前节点的枚举声明。
|
||||
Enum *Enum
|
||||
// Children 保存下一层嵌套类型。
|
||||
Children map[string]*TypeNode
|
||||
}
|
||||
|
||||
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
|
||||
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
|
||||
imports := make(map[string]bool)
|
||||
|
||||
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
// 仅导入 Google 标准类型。
|
||||
if refPkg == "google.protobuf" {
|
||||
var importFile string
|
||||
switch shortName {
|
||||
case "Struct", "Value", "ListValue", "NullValue":
|
||||
importFile = "google/protobuf/struct.proto"
|
||||
case "Timestamp":
|
||||
importFile = "google/protobuf/timestamp.proto"
|
||||
case "Duration":
|
||||
importFile = "google/protobuf/duration.proto"
|
||||
case "Any":
|
||||
importFile = "google/protobuf/any.proto"
|
||||
case "Empty":
|
||||
importFile = "google/protobuf/empty.proto"
|
||||
case "FieldMask":
|
||||
importFile = "google/protobuf/field_mask.proto"
|
||||
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
|
||||
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
|
||||
importFile = "google/protobuf/wrappers.proto"
|
||||
default:
|
||||
importFile = "google/protobuf/descriptor.proto"
|
||||
}
|
||||
imports[importFile] = true
|
||||
} else if refPkg == "google.rpc" {
|
||||
var importFile string
|
||||
switch shortName {
|
||||
case "Status":
|
||||
importFile = "google/rpc/status.proto"
|
||||
case "Code":
|
||||
importFile = "google/rpc/code.proto"
|
||||
default:
|
||||
importFile = "google/rpc/status.proto"
|
||||
}
|
||||
imports[importFile] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
for _, f := range msg.Fields {
|
||||
if f.Kind == "message" || f.Kind == "enum" {
|
||||
if ref, ok := f.T.(string); ok {
|
||||
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
|
||||
}
|
||||
}
|
||||
// map 值类型也可能引用标准包。
|
||||
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
|
||||
if ref, ok := f.MapValueT.(string); ok {
|
||||
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
for _, m := range svc.Methods {
|
||||
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
|
||||
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
|
||||
}
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// inputPaths 支持命令行重复传入 bundle 路径。
|
||||
type inputPaths []string
|
||||
|
||||
// String 返回已经登记的输入路径列表。
|
||||
func (paths *inputPaths) String() string {
|
||||
return fmt.Sprint([]string(*paths))
|
||||
}
|
||||
|
||||
// Set 追加一个去除空白后的输入路径。
|
||||
func (paths *inputPaths) Set(value string) error {
|
||||
*paths = append(*paths, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// bailIf 在不可恢复错误时打印信息并退出。
|
||||
func bailIf(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// findPrettier 定位可用的 prettier 命令。
|
||||
func findPrettier() (string, error) {
|
||||
// 尝试常见的 prettier 命令名
|
||||
names := []string{"prettier", "prettier.cmd", "npx"}
|
||||
for _, name := range names {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
|
||||
}
|
||||
|
||||
// main 解析参数、保护原始输入并执行协议提取。
|
||||
func main() {
|
||||
// 命令行参数
|
||||
var inputs inputPaths
|
||||
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
|
||||
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
|
||||
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
|
||||
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
|
||||
flag.Parse()
|
||||
|
||||
// 如果没有 -input 参数,尝试从位置参数获取
|
||||
if len(inputs) == 0 && flag.NArg() > 0 {
|
||||
inputs = append(inputs, flag.Args()...)
|
||||
}
|
||||
|
||||
if len(inputs) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
|
||||
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
|
||||
fmt.Fprintln(os.Stderr, "\nExample:")
|
||||
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
|
||||
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, inputPath := range inputs {
|
||||
info, err := os.Stat(inputPath)
|
||||
bailIf(err)
|
||||
if info.IsDir() {
|
||||
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
|
||||
}
|
||||
}
|
||||
|
||||
// 设置输出目录
|
||||
if *outputDir == "" {
|
||||
wd, err := os.Getwd()
|
||||
bailIf(err)
|
||||
*outputDir = filepath.Join(wd, "cursor_proto")
|
||||
}
|
||||
|
||||
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
|
||||
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
|
||||
tempFileNames := make([]string, 0, len(inputs))
|
||||
for _, inputPath := range inputs {
|
||||
originalFile, err := os.Open(inputPath)
|
||||
bailIf(err)
|
||||
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
|
||||
bailIf(err)
|
||||
_, err = io.Copy(tempFile, originalFile)
|
||||
bailIf(err)
|
||||
bailIf(originalFile.Close())
|
||||
bailIf(tempFile.Close())
|
||||
tempFileNames = append(tempFileNames, tempFile.Name())
|
||||
fmt.Printf("Source: %s\n", inputPath)
|
||||
}
|
||||
|
||||
if *skipFormat {
|
||||
fmt.Println("Skipping formatting (--skip-format)")
|
||||
} else if prettierBin, err := findPrettier(); err != nil {
|
||||
fmt.Printf("Warning: %v\n", err)
|
||||
fmt.Println("Skipping formatting, extraction may be less accurate...")
|
||||
} else {
|
||||
fmt.Println("Formatting source bundles (this may take a while)...")
|
||||
for _, tempFileName := range tempFileNames {
|
||||
var prettierCmd *exec.Cmd
|
||||
if filepath.Base(prettierBin) == "npx" {
|
||||
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
|
||||
} else {
|
||||
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
|
||||
}
|
||||
out, formatErr := prettierCmd.CombinedOutput()
|
||||
if formatErr != nil {
|
||||
fmt.Printf("Prettier output: %s\n", string(out))
|
||||
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 运行提取器
|
||||
fmt.Println("Extracting Proto definitions...")
|
||||
SetStrictMode(*strict)
|
||||
ExtractProtosFromFiles(tempFileNames, *outputDir)
|
||||
|
||||
for _, tempFileName := range tempFileNames {
|
||||
_ = os.Remove(tempFileName)
|
||||
}
|
||||
|
||||
fmt.Printf("\nOutput directory: %s\n", *outputDir)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// messages.go 解析消息声明、字段数组和字段类型信息。
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractMessages 从多种 bundle 语法中提取消息声明。
|
||||
func extractMessages(text string, moduleStarts []int) []Message {
|
||||
var messages []Message
|
||||
messageExists := func(typeName, varName string) bool {
|
||||
for _, existing := range messages {
|
||||
if existing.TypeName == typeName && existing.VarName == varName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 形式一:变量引用继承基类并在类体中声明 typeName 和 fields。
|
||||
// 先找所有 "变量名 = class 内部类名" 定义
|
||||
// JS 变量名可以包含 $ 符号,如 B$e, qg 等
|
||||
// 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个
|
||||
classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`)
|
||||
classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1)
|
||||
|
||||
// 从任意包的 this.typeName 字段读取完整类型名。
|
||||
typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`)
|
||||
|
||||
// 从 this.fields 的 newFieldList 回调读取字段数组。
|
||||
fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
|
||||
|
||||
for _, classMatch := range classMatches {
|
||||
varName := text[classMatch[2]:classMatch[3]]
|
||||
internalName := text[classMatch[4]:classMatch[5]]
|
||||
classStart := classMatch[0]
|
||||
|
||||
// 找到类的结束位置(匹配大括号)
|
||||
classEnd := findClassEnd(text, classMatch[1]-1)
|
||||
if classEnd == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
classBody := text[classStart:classEnd]
|
||||
|
||||
// 在类体内查找 typeName
|
||||
typeMatch := typeNameRe.FindStringSubmatch(classBody)
|
||||
if typeMatch == nil {
|
||||
continue
|
||||
}
|
||||
typeName := typeMatch[1]
|
||||
|
||||
// 在类体内查找 fields
|
||||
fieldsMatch := fieldsRe.FindStringIndex(classBody)
|
||||
if fieldsMatch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到 fields 数组的开始位置
|
||||
bracketPos := classStart + fieldsMatch[1] - 1
|
||||
fields := extractFieldArray(text, bracketPos)
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
msg := Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
InternalName: internalName,
|
||||
Fields: fields,
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: classStart,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, classStart),
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// 形式二:匹配转译或压缩 bundle 中连续赋值的消息声明。
|
||||
// 例如 i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}])。
|
||||
assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
|
||||
assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range assignmentMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
|
||||
// 跳过已经由类体形式提取的重复消息。
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 正则停在左方括号之前,从匹配尾部定位数组起点。
|
||||
start := m[1] - 1
|
||||
if start < 0 || start >= len(text) || text[start] != '[' {
|
||||
continue
|
||||
}
|
||||
fields := extractFieldArray(text, start)
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
InternalName: "",
|
||||
Fields: fields,
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
// 形式三:匹配现代 @bufbuild/protobuf 工厂调用。
|
||||
// 例如 Req=A.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{...}])。
|
||||
messageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\(\s*\)\s*=>\s*\[`)
|
||||
factoryMatches := messageFactoryRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range factoryMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
bracketStart := m[1] - 1
|
||||
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Fields: extractFieldArray(text, bracketStart),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
// 空消息直接传字段数组,不使用延迟回调。
|
||||
// 例如 Res=A.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[])。
|
||||
emptyMessageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
|
||||
emptyFactoryMatches := emptyMessageFactoryRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range emptyFactoryMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
bracketStart := m[1] - 1
|
||||
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Fields: extractFieldArray(text, bracketStart),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// findClassEnd 查找类定义的配对右花括号。
|
||||
func findClassEnd(text string, openBrace int) int {
|
||||
depth := 0
|
||||
for i := openBrace; i < len(text); i++ {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// extractFieldArray 从左方括号位置解析完整字段数组。
|
||||
func extractFieldArray(text string, start int) []Field {
|
||||
// 查找字段数组的配对右方括号。
|
||||
depth := 0
|
||||
end := start
|
||||
for i := start; i < len(text); i++ {
|
||||
if text[i] == '[' {
|
||||
depth++
|
||||
} else if text[i] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrayText := text[start:end]
|
||||
|
||||
// 按每个花括号块解析独立字段对象。
|
||||
var fields []Field
|
||||
|
||||
// 依次查找字段对象。
|
||||
fieldObjects := extractFieldObjects(arrayText)
|
||||
|
||||
for _, fieldObj := range fieldObjects {
|
||||
field, parseErr := parseFieldObject(fieldObj)
|
||||
if parseErr != nil {
|
||||
activeDiagnostics.addSkippedField(fieldObj, parseErr)
|
||||
continue
|
||||
}
|
||||
activeDiagnostics.addParsedField()
|
||||
fields = append(fields, *field)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractFieldObjects 从数组文本中提取独立字段对象。
|
||||
func extractFieldObjects(arrayText string) []string {
|
||||
var objects []string
|
||||
depth := 0
|
||||
start := -1
|
||||
|
||||
for i := 0; i < len(arrayText); i++ {
|
||||
if arrayText[i] == '{' {
|
||||
if depth == 0 {
|
||||
start = i
|
||||
}
|
||||
depth++
|
||||
} else if arrayText[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 && start >= 0 {
|
||||
objects = append(objects, arrayText[start:i+1])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects
|
||||
}
|
||||
|
||||
// parseFieldObject 解析包含编号、名称、类型和修饰符的单个字段对象。
|
||||
func parseFieldObject(obj string) (*Field, error) {
|
||||
// 提取字段编号。
|
||||
noMatch := noRe.FindStringSubmatch(obj)
|
||||
if noMatch == nil {
|
||||
return nil, errors.New("missing field no")
|
||||
}
|
||||
no, _ := strconv.Atoi(noMatch[1])
|
||||
|
||||
// 提取字段名称。
|
||||
nameMatch := nameRe.FindStringSubmatch(obj)
|
||||
if nameMatch == nil {
|
||||
return nil, errors.New("missing field name")
|
||||
}
|
||||
name := strings.TrimSpace(nameMatch[1])
|
||||
if !fieldNameRe.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid field name: %s", name)
|
||||
}
|
||||
|
||||
// 提取字段类别。
|
||||
kindMatch := kindRe.FindStringSubmatch(obj)
|
||||
if kindMatch == nil {
|
||||
return nil, errors.New("missing field kind")
|
||||
}
|
||||
kind := strings.TrimSpace(kindMatch[1])
|
||||
|
||||
field := &Field{
|
||||
No: no,
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
}
|
||||
|
||||
// 类型 T 可以是标量编号、变量名或 getEnumType 枚举调用。
|
||||
|
||||
// 枚举优先匹配 getEnumType 调用。
|
||||
if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil {
|
||||
field.T = enumMatch[1]
|
||||
} else {
|
||||
// 其余类型匹配普通 T 属性值。
|
||||
if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil {
|
||||
if t, err := strconv.Atoi(tMatch[1]); err == nil {
|
||||
field.T = t
|
||||
} else {
|
||||
field.T = tMatch[1]
|
||||
}
|
||||
} else if shorthandTRe.MatchString(obj) {
|
||||
field.T = "T"
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 oneof 分组。
|
||||
if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil {
|
||||
candidate := strings.TrimSpace(oneofMatch[1])
|
||||
if oneofNameRe.MatchString(candidate) {
|
||||
field.Oneof = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 repeated;压缩 JS 中 !0 表示真。
|
||||
if repeatedRe.MatchString(obj) {
|
||||
field.Repeated = true
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 optional。
|
||||
if optRe.MatchString(obj) {
|
||||
field.Opt = true
|
||||
}
|
||||
|
||||
// map 字段通过 K 键类型和 V 值描述共同表示。
|
||||
if field.Kind == "map" {
|
||||
// 提取 map 键类型。
|
||||
if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil {
|
||||
field.MapKey, _ = strconv.Atoi(keyMatch[1])
|
||||
}
|
||||
|
||||
// 提取 map 值类型,兼容属性顺序变化。
|
||||
if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil {
|
||||
valueObj := valueMatch[1]
|
||||
if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil {
|
||||
field.MapValueKind = kindMatch[1]
|
||||
}
|
||||
if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil {
|
||||
if t, err := strconv.Atoi(tMatch[1]); err == nil {
|
||||
field.MapValueT = t
|
||||
} else {
|
||||
field.MapValueT = tMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return field, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user