Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
622a57fed7 | ||
|
|
639c452a00 | ||
|
|
0c9a07e018 | ||
|
|
1adca7f600 | ||
|
|
5df3e8f512 | ||
|
|
a2e3a07773 | ||
|
|
6b7c077448 | ||
|
|
c64911e8c6 | ||
|
|
d1d56bd6eb | ||
|
|
ee47f187f0 | ||
|
|
726f082e6b | ||
|
|
424235f11d | ||
|
|
58770aa206 | ||
|
|
eb0ad7b636 | ||
|
|
697fa99245 | ||
|
|
1854a3ab9a | ||
|
|
5742073be5 | ||
|
|
90d35085c0 | ||
|
|
7764eca0c3 | ||
|
|
a5437e60fe | ||
|
|
674de0b041 | ||
|
|
d1ffd1a95c | ||
|
|
9d316c0b3d | ||
|
|
834288839b | ||
|
|
68e63c7a72 | ||
|
|
2c650b3765 | ||
|
|
14b286a466 | ||
|
|
828e0a9941 | ||
|
|
00f52166b2 | ||
|
|
3349b13a2b | ||
|
|
4451a6df3b | ||
|
|
7b1c1d39af | ||
|
|
17f30a8852 | ||
|
|
1f976b2a5e | ||
|
|
f3a7aece9d | ||
|
|
3a3be0c9e9 | ||
|
|
9e05739969 | ||
|
|
4bb0cbc1fc | ||
|
|
0f6b0f9484 | ||
|
|
e401303f64 | ||
|
|
799dbda7e0 | ||
|
|
f3cbf1e6c3 | ||
|
|
2380a0d692 | ||
|
|
1da1faa4d4 | ||
|
|
ce05b0bf7a | ||
|
|
44a47992d9 | ||
|
|
319bd3d7d8 | ||
|
|
12c7973a06 | ||
|
|
f93b18740d | ||
|
|
c6ee26b12d | ||
|
|
7c1c6a134b | ||
|
|
e0aa270328 | ||
|
|
556ea21f38 | ||
|
|
315aba49c2 | ||
|
|
6ab537ad4e | ||
|
|
6d7a4e32fe | ||
|
|
50ab63de3d | ||
|
|
802daa8fcd | ||
|
|
eb2c236699 | ||
|
|
86a655fb5d | ||
|
|
c50e7d0606 | ||
|
|
4ec7b4289f | ||
|
|
6bebe37bfc | ||
|
|
90c3c35774 | ||
|
|
64a0e09605 | ||
|
|
47d336ac81 | ||
|
|
268e9fbde0 | ||
|
|
7d89b0af2a | ||
|
|
c083be5ec2 |
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "本地模式实现指南"
|
||||||
|
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
|
||||||
|
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 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`,再用日志解释运行时经过了哪条路径。
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 文件地图
|
||||||
|
|
||||||
|
## 已安装客户端 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`
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 已安装客户端只读核对与验证
|
||||||
|
|
||||||
|
首要原则:
|
||||||
|
|
||||||
|
- 不要修改已安装的 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、重签名、替换文件或做写入式验证。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# 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`
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 搜索词与判断树
|
||||||
|
|
||||||
|
## 先判断层级
|
||||||
|
|
||||||
|
### `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`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/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"
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
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`,并把结论标成推断,而不是直接证明。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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."
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
*
|
||||||
|
!go.mod
|
||||||
|
!go.sum
|
||||||
|
!cursor-tab-server/
|
||||||
|
!cursor-tab-server/**
|
||||||
|
!gen/
|
||||||
|
!gen/**
|
||||||
|
!internal/
|
||||||
|
!internal/**
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## 变更说明 / 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 -->
|
||||||
|
|
||||||
|
## 检查清单 / Checklist
|
||||||
|
|
||||||
|
- [ ] 代码已通过 `gofmt` 和 `go vet` / Code passes `gofmt` and `go vet`
|
||||||
|
- [ ] 前端构建无报错 / Frontend builds without errors
|
||||||
|
- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files
|
||||||
|
- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
claude-server.tar
|
||||||
|
dist
|
||||||
|
.task
|
||||||
|
bin
|
||||||
|
logs/
|
||||||
|
gen/
|
||||||
|
logs.zip
|
||||||
|
frontend/bindings
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
cursor-server.tar
|
||||||
|
server-node/cursor.tar
|
||||||
|
server-go/cursor.tar
|
||||||
|
server-go/log/
|
||||||
|
.cursor-local-assistant
|
||||||
|
.cursor-local-assistant-v2
|
||||||
|
.cursor-app-formatted/
|
||||||
|
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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
> 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) 授权你的贡献。
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 leookun
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -5,12 +5,35 @@
|
|||||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## 交流群组
|
||||||
|
https://t.me/cursor_byok
|
||||||
|
|
||||||
|
|
||||||
|
## 为什么做这个项目
|
||||||
|
|
||||||
|
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。
|
||||||
|
|
||||||
|
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
|
||||||
|
|
||||||
|
这个项目的目标,是让模型选择权重新回到用户手里。
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
|
||||||
|
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||||
|
[详细使用教程](https://docs.leokun.cn)
|
||||||
|
|
||||||
|
## 后续
|
||||||
|
|
||||||
|
后续会继续扩展更多工具和使用场景,包括但不限于:
|
||||||
|
|
||||||
|
- 支持更多 IDE 接入
|
||||||
|
- 支持更多 Chat 类应用
|
||||||
|
- 支持更多 Agent 工具和工作流
|
||||||
|
- 提供更完善的自托管部署方式
|
||||||
|
- 持续优化不同模型 API 的兼容性
|
||||||
|
- 降低接入成本,让已有模型额度可以被更充分地利用
|
||||||
|
|
||||||
|
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
|
||||||
|
task build
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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 ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||||
|
msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||||
|
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
|
||||||
|
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||||
|
- task: generate:proto
|
||||||
|
|
||||||
|
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
|
||||||
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
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.44"
|
||||||
|
|
||||||
|
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: 其他数据
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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.44</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.44</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>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?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.44</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.44</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>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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}}'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/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 "按回车退出..."
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//@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);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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}}'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[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助手
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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.44"
|
||||||
|
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"
|
||||||
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,156 @@
|
|||||||
|
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}}'
|
||||||
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "0.0.44"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "0.0.44",
|
||||||
|
"CompanyName": "Cursor助手",
|
||||||
|
"FileDescription": "Cursor助手",
|
||||||
|
"LegalCopyright": "© 2026, Cursor助手",
|
||||||
|
"ProductName": "Cursor助手",
|
||||||
|
"Comments": "Cursor助手"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# 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.44"
|
||||||
|
!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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?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.44" 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>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
proxydebugger "cursor/cursor-proxy-debugger"
|
||||||
|
|
||||||
|
"github.com/pkg/browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := proxydebugger.Config{}
|
||||||
|
openBrowser := true
|
||||||
|
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
|
||||||
|
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
|
||||||
|
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
|
||||||
|
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||||
|
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
server, err := proxydebugger.New(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Cursor 协议调试代理已启动\n")
|
||||||
|
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
|
||||||
|
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||||
|
if openBrowser {
|
||||||
|
_ = browser.OpenURL(server.UIURL())
|
||||||
|
}
|
||||||
|
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-signals
|
||||||
|
|
||||||
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Close(shutdownContext); err != nil {
|
||||||
|
log.Printf("关闭调试代理失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Cursor Protocol Debugger
|
||||||
|
|
||||||
|
[中文](README.md) | [English](README.en.md)
|
||||||
|
|
||||||
|
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend` and `RunSSE` traffic. It does not modify Cursor, the system proxy, or the installed client.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Run the following command from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/cursor-proxy-debugger
|
||||||
|
```
|
||||||
|
|
||||||
|
Default addresses:
|
||||||
|
|
||||||
|
- HTTP/HTTPS proxy: `127.0.0.1:9090`
|
||||||
|
- Debugging UI: `http://127.0.0.1:9091`
|
||||||
|
- MITM target: `api2.cursor.sh`
|
||||||
|
|
||||||
|
The debugging UI opens automatically after startup.
|
||||||
|
|
||||||
|
## Configure Cursor
|
||||||
|
|
||||||
|
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
|
||||||
|
|
||||||
|
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
|
||||||
|
2. Open Cursor's Network settings and enable HTTP/1.1.
|
||||||
|
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
|
||||||
|
|
||||||
|
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
```text
|
||||||
|
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
|
||||||
|
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
|
||||||
|
-target-host Host to decrypt and capture; default: api2.cursor.sh
|
||||||
|
-max-exchanges Maximum number of exchanges retained in memory; default: 200
|
||||||
|
-open Open the browser after startup; default: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Handling
|
||||||
|
|
||||||
|
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
|
||||||
|
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
||||||
|
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
||||||
|
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
|
||||||
|
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
|
||||||
|
- Captured traffic is stored only in process memory and is discarded when the process exits.
|
||||||
|
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
|
||||||
|
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Cursor 协议调试器
|
||||||
|
|
||||||
|
[中文](README.md) | [English](README.en.md)
|
||||||
|
|
||||||
|
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend` 和 `RunSSE` 通信。它不会修改 Cursor、系统代理或已安装客户端。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
在仓库根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/cursor-proxy-debugger
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听:
|
||||||
|
|
||||||
|
- HTTP/HTTPS 代理:`127.0.0.1:9090`
|
||||||
|
- 调试界面:`http://127.0.0.1:9091`
|
||||||
|
- MITM 目标:`api2.cursor.sh`
|
||||||
|
|
||||||
|
启动后会自动打开调试界面。
|
||||||
|
|
||||||
|
## 配置 Cursor
|
||||||
|
|
||||||
|
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
|
||||||
|
|
||||||
|
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`。
|
||||||
|
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
|
||||||
|
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||||
|
|
||||||
|
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
```text
|
||||||
|
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
|
||||||
|
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
|
||||||
|
-target-host 需要解密的目标主机,默认 api2.cursor.sh
|
||||||
|
-max-exchanges 内存中保留的最大请求数,默认 200
|
||||||
|
-open 启动后是否打开浏览器,默认 true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据处理
|
||||||
|
|
||||||
|
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
|
||||||
|
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||||
|
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||||
|
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
|
||||||
|
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
|
||||||
|
- 抓包只保留在当前进程内存中;关闭进程后消失。
|
||||||
|
- `Authorization`、`Cookie`、`Set-Cookie` 等 HTTP 头在界面中默认隐藏。
|
||||||
|
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type captureReadCloser struct {
|
||||||
|
source io.ReadCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
buffer bytes.Buffer
|
||||||
|
limit int
|
||||||
|
size int64
|
||||||
|
truncated bool
|
||||||
|
done bool
|
||||||
|
onChunk func([]byte)
|
||||||
|
onDone func(captured []byte, size int64, truncated bool, readErr error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCaptureReadCloser(
|
||||||
|
source io.ReadCloser,
|
||||||
|
limit int,
|
||||||
|
onChunk func([]byte),
|
||||||
|
onDone func(captured []byte, size int64, truncated bool, readErr error),
|
||||||
|
) *captureReadCloser {
|
||||||
|
return &captureReadCloser{
|
||||||
|
source: source,
|
||||||
|
limit: limit,
|
||||||
|
onChunk: onChunk,
|
||||||
|
onDone: onDone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
|
||||||
|
read, err := reader.source.Read(payload)
|
||||||
|
if read > 0 {
|
||||||
|
chunk := payload[:read]
|
||||||
|
reader.mu.Lock()
|
||||||
|
reader.size += int64(read)
|
||||||
|
remaining := reader.limit - reader.buffer.Len()
|
||||||
|
if remaining > 0 {
|
||||||
|
captured := read
|
||||||
|
if captured > remaining {
|
||||||
|
captured = remaining
|
||||||
|
}
|
||||||
|
_, _ = reader.buffer.Write(chunk[:captured])
|
||||||
|
}
|
||||||
|
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
|
||||||
|
reader.truncated = true
|
||||||
|
}
|
||||||
|
reader.mu.Unlock()
|
||||||
|
if reader.onChunk != nil {
|
||||||
|
reader.onChunk(append([]byte(nil), chunk...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
reader.finish(err)
|
||||||
|
}
|
||||||
|
return read, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *captureReadCloser) Close() error {
|
||||||
|
err := reader.source.Close()
|
||||||
|
reader.finish(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *captureReadCloser) finish(readErr error) {
|
||||||
|
reader.mu.Lock()
|
||||||
|
if reader.done {
|
||||||
|
reader.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader.done = true
|
||||||
|
captured := append([]byte(nil), reader.buffer.Bytes()...)
|
||||||
|
size := reader.size
|
||||||
|
truncated := reader.truncated
|
||||||
|
reader.mu.Unlock()
|
||||||
|
if reader.onDone != nil {
|
||||||
|
reader.onDone(captured, size, truncated, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawHex(payload []byte) string {
|
||||||
|
return hex.EncodeToString(payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cursor/gen/agentv1"
|
||||||
|
"cursor/gen/aiserverv1"
|
||||||
|
agentprotocol "cursor/internal/backend/agent/protocol"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxConnectFrameBytes = 64 << 20
|
||||||
|
|
||||||
|
type connectFrameDecoder struct {
|
||||||
|
buffer []byte
|
||||||
|
messageType string
|
||||||
|
codec string
|
||||||
|
maxFrames int
|
||||||
|
frameCount int
|
||||||
|
onFrame func(FrameView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
|
||||||
|
return &connectFrameDecoder{
|
||||||
|
messageType: messageType,
|
||||||
|
codec: strings.TrimSpace(codec),
|
||||||
|
maxFrames: maxFrames,
|
||||||
|
onFrame: onFrame,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (decoder *connectFrameDecoder) Write(payload []byte) {
|
||||||
|
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decoder.buffer = append(decoder.buffer, payload...)
|
||||||
|
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
|
||||||
|
flags := decoder.buffer[0]
|
||||||
|
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
|
||||||
|
if length < 0 || length > maxConnectFrameBytes {
|
||||||
|
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
|
||||||
|
decoder.buffer = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(decoder.buffer) < 5+length {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
|
||||||
|
decoder.buffer = decoder.buffer[5+length:]
|
||||||
|
decoder.emit(decoder.decode(flags, framePayload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (decoder *connectFrameDecoder) Close() {
|
||||||
|
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
|
||||||
|
decoder.emit(FrameView{
|
||||||
|
Length: len(decoder.buffer),
|
||||||
|
RawHex: clippedHex(decoder.buffer, 4096),
|
||||||
|
Error: "流结束时仍有不完整的 Connect 帧",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
decoder.buffer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (decoder *connectFrameDecoder) emit(frame FrameView) {
|
||||||
|
frame.Index = decoder.frameCount
|
||||||
|
decoder.frameCount++
|
||||||
|
if decoder.onFrame != nil {
|
||||||
|
decoder.onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
|
||||||
|
frame := FrameView{
|
||||||
|
Flags: flags,
|
||||||
|
Length: len(payload),
|
||||||
|
Compressed: flags&0x01 != 0,
|
||||||
|
EndStream: flags&0x02 != 0,
|
||||||
|
RawHex: clippedHex(payload, 4096),
|
||||||
|
}
|
||||||
|
decoded := payload
|
||||||
|
if frame.Compressed {
|
||||||
|
var err error
|
||||||
|
decoded, err = decompressPayload(payload, decoder.codec)
|
||||||
|
if err != nil {
|
||||||
|
frame.Error = err.Error()
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if frame.EndStream {
|
||||||
|
frame.Kind = "end_stream"
|
||||||
|
frame.MessageType = "connect.error.v1.EndStreamResponse"
|
||||||
|
frame.JSON = prettyJSON(decoded)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
message := newMessage(decoder.messageType)
|
||||||
|
if message == nil {
|
||||||
|
frame.Error = "未知的 protobuf 消息类型"
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
if err := proto.Unmarshal(decoded, message); err != nil {
|
||||||
|
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
frame.MessageType = decoder.messageType
|
||||||
|
frame.Kind = activeOneofName(message)
|
||||||
|
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
|
||||||
|
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
|
||||||
|
}
|
||||||
|
frame.JSON = marshalProtoJSON(message)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
func decompressPayload(payload []byte, codec string) ([]byte, error) {
|
||||||
|
if codec != "" && !strings.EqualFold(codec, "gzip") {
|
||||||
|
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
|
||||||
|
}
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gzip 解压失败:%w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) > maxConnectFrameBytes {
|
||||||
|
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeUnary(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
|
||||||
|
var message proto.Message
|
||||||
|
switch path {
|
||||||
|
case "/aiserver.v1.BidiService/BidiAppend":
|
||||||
|
request := &aiserverv1.BidiAppendRequest{}
|
||||||
|
if err := proto.Unmarshal(payload, request); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
|
||||||
|
outer := marshalProtoJSON(request)
|
||||||
|
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
|
||||||
|
if decodeErr != nil || clientMessage == nil {
|
||||||
|
return outer, "bidi_append", requestID, decodeErr
|
||||||
|
}
|
||||||
|
combined := struct {
|
||||||
|
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
|
||||||
|
AgentClientKind string `json:"agent_client_kind"`
|
||||||
|
AgentClient json.RawMessage `json:"agent_client_message"`
|
||||||
|
}{
|
||||||
|
BidiAppendRequest: json.RawMessage(outer),
|
||||||
|
AgentClientKind: clientKind,
|
||||||
|
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
|
||||||
|
}
|
||||||
|
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
|
||||||
|
return string(formatted), clientKind, requestID, marshalErr
|
||||||
|
default:
|
||||||
|
message = nil
|
||||||
|
}
|
||||||
|
if message == nil {
|
||||||
|
return "", "", "", nil
|
||||||
|
}
|
||||||
|
return marshalProtoJSON(message), activeOneofName(message), "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessage(messageType string) proto.Message {
|
||||||
|
switch messageType {
|
||||||
|
case "aiserver.v1.BidiRequestId":
|
||||||
|
return &aiserverv1.BidiRequestId{}
|
||||||
|
case "agent.v1.AgentServerMessage":
|
||||||
|
return &agentv1.AgentServerMessage{}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalProtoJSON(message proto.Message) string {
|
||||||
|
if message == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := (protojson.MarshalOptions{
|
||||||
|
UseProtoNames: true,
|
||||||
|
EmitUnpopulated: false,
|
||||||
|
Indent: " ",
|
||||||
|
}).Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeOneofName(message proto.Message) string {
|
||||||
|
if message == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
reflected := message.ProtoReflect()
|
||||||
|
oneofs := reflected.Descriptor().Oneofs()
|
||||||
|
for index := 0; index < oneofs.Len(); index++ {
|
||||||
|
oneof := oneofs.Get(index)
|
||||||
|
field := reflected.WhichOneof(oneof)
|
||||||
|
if field != nil {
|
||||||
|
return string(field.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(reflected.Descriptor().Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyJSON(payload []byte) string {
|
||||||
|
var target any
|
||||||
|
if err := json.Unmarshal(payload, &target); err != nil {
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
formatted, err := json.MarshalIndent(target, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return string(payload)
|
||||||
|
}
|
||||||
|
return string(formatted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clippedHex(payload []byte, max int) string {
|
||||||
|
if len(payload) > max {
|
||||||
|
return hex.EncodeToString(payload[:max]) + "..."
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cursor/internal/certs"
|
||||||
|
|
||||||
|
"github.com/elazarl/goproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exchangeContext struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server runs the HTTPS debugging proxy and its local web UI.
|
||||||
|
type Server struct {
|
||||||
|
config Config
|
||||||
|
certManager *certs.Manager
|
||||||
|
store *exchangeStore
|
||||||
|
counter atomic.Uint64
|
||||||
|
proxyServer *http.Server
|
||||||
|
uiServer *http.Server
|
||||||
|
proxyLn net.Listener
|
||||||
|
uiLn net.Listener
|
||||||
|
runMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a standalone Cursor protocol debugger.
|
||||||
|
func New(config Config) (*Server, error) {
|
||||||
|
config = config.normalized()
|
||||||
|
if err := validateLoopbackAddress(config.UIAddr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
manager, err := certs.NewEmbeddedManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
|
||||||
|
}
|
||||||
|
server := &Server{
|
||||||
|
config: config,
|
||||||
|
certManager: manager,
|
||||||
|
store: newExchangeStore(config.MaxExchanges),
|
||||||
|
}
|
||||||
|
proxyHandler, err := server.newProxyHandler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
server.proxyServer = &http.Server{
|
||||||
|
Handler: proxyHandler,
|
||||||
|
ErrorLog: log.New(io.Discard, "", 0),
|
||||||
|
}
|
||||||
|
server.uiServer = &http.Server{Handler: server.newUIHandler()}
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts both listeners without modifying Cursor or system proxy settings.
|
||||||
|
func (server *Server) Start() error {
|
||||||
|
server.runMu.Lock()
|
||||||
|
defer server.runMu.Unlock()
|
||||||
|
if server.proxyLn != nil || server.uiLn != nil {
|
||||||
|
return errors.New("调试代理已经启动")
|
||||||
|
}
|
||||||
|
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("启动代理监听失败:%w", err)
|
||||||
|
}
|
||||||
|
uiListener, err := net.Listen("tcp", server.config.UIAddr)
|
||||||
|
if err != nil {
|
||||||
|
_ = proxyListener.Close()
|
||||||
|
return fmt.Errorf("启动调试界面失败:%w", err)
|
||||||
|
}
|
||||||
|
server.proxyLn = proxyListener
|
||||||
|
server.uiLn = uiListener
|
||||||
|
go func() { _ = server.proxyServer.Serve(proxyListener) }()
|
||||||
|
go func() { _ = server.uiServer.Serve(uiListener) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops both listeners.
|
||||||
|
func (server *Server) Close(ctx context.Context) error {
|
||||||
|
server.runMu.Lock()
|
||||||
|
proxyServer := server.proxyServer
|
||||||
|
uiServer := server.uiServer
|
||||||
|
server.proxyLn = nil
|
||||||
|
server.uiLn = nil
|
||||||
|
server.runMu.Unlock()
|
||||||
|
var errorsList []error
|
||||||
|
if proxyServer != nil {
|
||||||
|
if err := proxyServer.Shutdown(ctx); err != nil {
|
||||||
|
errorsList = append(errorsList, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uiServer != nil {
|
||||||
|
if err := uiServer.Shutdown(ctx); err != nil {
|
||||||
|
errorsList = append(errorsList, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errorsList...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
|
||||||
|
func (server *Server) UIAddr() string { return server.config.UIAddr }
|
||||||
|
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
|
||||||
|
|
||||||
|
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
|
||||||
|
proxy := goproxy.NewProxyHttpServer()
|
||||||
|
proxy.Verbose = false
|
||||||
|
proxy.AllowHTTP2 = true
|
||||||
|
proxy.Logger = log.New(io.Discard, "", 0)
|
||||||
|
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
|
||||||
|
id := exchangeID(context)
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
exchange.State = "error"
|
||||||
|
exchange.Error = connectionErr.Error()
|
||||||
|
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
proxy.Tr = &http.Transport{
|
||||||
|
Proxy: nil,
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
MaxIdleConns: 200,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertificate, err := server.certManager.CATLSCertificate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
|
||||||
|
}
|
||||||
|
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
|
||||||
|
mitmAction := &goproxy.ConnectAction{
|
||||||
|
Action: goproxy.ConnectMitm,
|
||||||
|
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
|
||||||
|
return baseTLSConfig(host, context)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||||
|
if server.matchesTargetHost(host) {
|
||||||
|
return mitmAction, host
|
||||||
|
}
|
||||||
|
return goproxy.OkConnect, host
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy.OnRequest().DoFunc(server.captureRequest)
|
||||||
|
proxy.OnResponse().DoFunc(server.captureResponse)
|
||||||
|
return proxy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||||
|
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||||
|
path := request.URL.Path
|
||||||
|
requestCodec := requestContentCodec(path, request.Header)
|
||||||
|
exchange := &Exchange{
|
||||||
|
ExchangeSummary: ExchangeSummary{
|
||||||
|
ID: id,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Method: request.Method,
|
||||||
|
URL: request.URL.String(),
|
||||||
|
Host: request.URL.Host,
|
||||||
|
Path: path,
|
||||||
|
State: "pending",
|
||||||
|
},
|
||||||
|
Request: Payload{
|
||||||
|
Headers: sortedHeaders(request.Header),
|
||||||
|
ContentType: request.Header.Get("Content-Type"),
|
||||||
|
ContentCodec: requestCodec,
|
||||||
|
Frames: make([]FrameView, 0),
|
||||||
|
},
|
||||||
|
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||||
|
}
|
||||||
|
server.store.create(exchange)
|
||||||
|
context.UserData = exchangeContext{id: id}
|
||||||
|
|
||||||
|
if request.Body == nil {
|
||||||
|
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
var frameDecoder *connectFrameDecoder
|
||||||
|
if path == "/agent.v1.AgentService/RunSSE" {
|
||||||
|
frameDecoder = newConnectFrameDecoder(
|
||||||
|
"aiserver.v1.BidiRequestId",
|
||||||
|
requestCodec,
|
||||||
|
server.config.MaxFrames,
|
||||||
|
func(frame FrameView) { server.appendRequestFrame(id, frame) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
request.Body = newCaptureReadCloser(
|
||||||
|
request.Body,
|
||||||
|
server.config.MaxCaptureBytes,
|
||||||
|
func(chunk []byte) {
|
||||||
|
if frameDecoder != nil {
|
||||||
|
frameDecoder.Write(chunk)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||||
|
if frameDecoder != nil {
|
||||||
|
frameDecoder.Close()
|
||||||
|
}
|
||||||
|
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
|
||||||
|
id := exchangeID(context)
|
||||||
|
if id == "" || response == nil {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
exchange.Status = response.StatusCode
|
||||||
|
exchange.State = "streaming"
|
||||||
|
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||||
|
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||||
|
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
||||||
|
exchange.Response.ContentCodec = responseContentCodec(response.Header)
|
||||||
|
})
|
||||||
|
if response.Body == nil {
|
||||||
|
server.finishResponseBody(id, nil, 0, false, nil)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
path := ""
|
||||||
|
if response.Request != nil && response.Request.URL != nil {
|
||||||
|
path = response.Request.URL.Path
|
||||||
|
}
|
||||||
|
var frameDecoder *connectFrameDecoder
|
||||||
|
if path == "/agent.v1.AgentService/RunSSE" {
|
||||||
|
frameDecoder = newConnectFrameDecoder(
|
||||||
|
"agent.v1.AgentServerMessage",
|
||||||
|
response.Header.Get("Connect-Content-Encoding"),
|
||||||
|
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, captured, size, truncated, readErr)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||||
|
decodePayload := captured
|
||||||
|
var contentDecodeErr error
|
||||||
|
if path == "/aiserver.v1.BidiService/BidiAppend" && truncated {
|
||||||
|
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||||
|
} else if path == "/aiserver.v1.BidiService/BidiAppend" && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||||
|
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||||
|
}
|
||||||
|
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
||||||
|
if decodeErr == nil {
|
||||||
|
decodedJSON, kind, requestID, decodeErr = decodeUnary(path, decodePayload)
|
||||||
|
}
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
exchange.RequestBytes = size
|
||||||
|
exchange.Request.Size = size
|
||||||
|
exchange.Request.RawHex = rawHex(captured)
|
||||||
|
exchange.Request.RawTruncated = truncated
|
||||||
|
if decodedJSON != "" {
|
||||||
|
exchange.Request.DecodedJSON = decodedJSON
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
exchange.RequestKind = kind
|
||||||
|
}
|
||||||
|
if requestID != "" {
|
||||||
|
exchange.RequestID = requestID
|
||||||
|
}
|
||||||
|
if decodeErr != nil {
|
||||||
|
exchange.Request.DecodeError = decodeErr.Error()
|
||||||
|
}
|
||||||
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
|
exchange.Error = readErr.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestContentCodec(path string, headers http.Header) string {
|
||||||
|
if path == "/agent.v1.AgentService/RunSSE" {
|
||||||
|
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseContentCodec(headers http.Header) string {
|
||||||
|
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||||
|
return codec
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) finishResponseBody(id string, captured []byte, size int64, truncated bool, readErr error) {
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
exchange.ResponseBytes = size
|
||||||
|
exchange.Response.Size = size
|
||||||
|
exchange.Response.RawHex = rawHex(captured)
|
||||||
|
exchange.Response.RawTruncated = truncated
|
||||||
|
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||||
|
exchange.State = "completed"
|
||||||
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
|
exchange.State = "error"
|
||||||
|
exchange.Error = readErr.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
if len(exchange.Request.Frames) < server.config.MaxFrames {
|
||||||
|
exchange.Request.Frames = append(exchange.Request.Frames, frame)
|
||||||
|
}
|
||||||
|
if frame.Kind != "" {
|
||||||
|
exchange.RequestKind = frame.Kind
|
||||||
|
}
|
||||||
|
if frame.RequestID != "" {
|
||||||
|
exchange.RequestID = frame.RequestID
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||||
|
server.store.update(id, func(exchange *Exchange) {
|
||||||
|
if len(exchange.Response.Frames) < server.config.MaxFrames {
|
||||||
|
exchange.Response.Frames = append(exchange.Response.Frames, frame)
|
||||||
|
}
|
||||||
|
exchange.FrameCount = len(exchange.Response.Frames)
|
||||||
|
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||||
|
exchange.ResponseKind = frame.Kind
|
||||||
|
}
|
||||||
|
if frame.Error != "" {
|
||||||
|
exchange.Response.DecodeError = frame.Error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
|
||||||
|
if request == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := request.Host
|
||||||
|
if request.URL != nil && request.URL.Host != "" {
|
||||||
|
host = request.URL.Host
|
||||||
|
}
|
||||||
|
return server.matchesTargetHost(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) matchesTargetHost(host string) bool {
|
||||||
|
host = strings.TrimSpace(strings.ToLower(host))
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = parsedHost
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
|
||||||
|
return host == target
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeID(context *goproxy.ProxyCtx) string {
|
||||||
|
if context == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value, ok := context.UserData.(exchangeContext)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func browserAddress(address string) string {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLoopbackAddress(address string) error {
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("调试界面监听地址无效:%w", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil || !ip.IsLoopback() {
|
||||||
|
return errors.New("调试界面只能监听本机回环地址")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exchangeStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
max int
|
||||||
|
order []string
|
||||||
|
exchanges map[string]*Exchange
|
||||||
|
subscribers map[chan storeEvent]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExchangeStore(max int) *exchangeStore {
|
||||||
|
return &exchangeStore{
|
||||||
|
max: max,
|
||||||
|
exchanges: make(map[string]*Exchange),
|
||||||
|
subscribers: make(map[chan storeEvent]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) create(exchange *Exchange) {
|
||||||
|
store.mu.Lock()
|
||||||
|
store.exchanges[exchange.ID] = exchange
|
||||||
|
store.order = append([]string{exchange.ID}, store.order...)
|
||||||
|
for len(store.order) > store.max {
|
||||||
|
oldest := store.order[len(store.order)-1]
|
||||||
|
store.order = store.order[:len(store.order)-1]
|
||||||
|
delete(store.exchanges, oldest)
|
||||||
|
}
|
||||||
|
store.mu.Unlock()
|
||||||
|
store.publish(storeEvent{Type: "created", ID: exchange.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
|
||||||
|
store.mu.Lock()
|
||||||
|
if exchange := store.exchanges[id]; exchange != nil {
|
||||||
|
apply(exchange)
|
||||||
|
}
|
||||||
|
store.mu.Unlock()
|
||||||
|
store.publish(storeEvent{Type: "updated", ID: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) summaries() []ExchangeSummary {
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
result := make([]ExchangeSummary, 0, len(store.order))
|
||||||
|
for _, id := range store.order {
|
||||||
|
if exchange := store.exchanges[id]; exchange != nil {
|
||||||
|
result = append(result, exchange.ExchangeSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) get(id string) (Exchange, bool) {
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
exchange := store.exchanges[id]
|
||||||
|
if exchange == nil {
|
||||||
|
return Exchange{}, false
|
||||||
|
}
|
||||||
|
return cloneExchange(*exchange), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) clear() {
|
||||||
|
store.mu.Lock()
|
||||||
|
store.order = nil
|
||||||
|
store.exchanges = make(map[string]*Exchange)
|
||||||
|
store.mu.Unlock()
|
||||||
|
store.publish(storeEvent{Type: "cleared"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
|
||||||
|
updates := make(chan storeEvent, 32)
|
||||||
|
store.mu.Lock()
|
||||||
|
store.subscribers[updates] = struct{}{}
|
||||||
|
store.mu.Unlock()
|
||||||
|
return updates, func() {
|
||||||
|
store.mu.Lock()
|
||||||
|
if _, ok := store.subscribers[updates]; ok {
|
||||||
|
delete(store.subscribers, updates)
|
||||||
|
close(updates)
|
||||||
|
}
|
||||||
|
store.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *exchangeStore) publish(event storeEvent) {
|
||||||
|
store.mu.RLock()
|
||||||
|
defer store.mu.RUnlock()
|
||||||
|
for subscriber := range store.subscribers {
|
||||||
|
select {
|
||||||
|
case subscriber <- event:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneExchange(exchange Exchange) Exchange {
|
||||||
|
exchange.Request = clonePayload(exchange.Request)
|
||||||
|
exchange.Response = clonePayload(exchange.Response)
|
||||||
|
return exchange
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePayload(payload Payload) Payload {
|
||||||
|
payload.Headers = append([]Header(nil), payload.Headers...)
|
||||||
|
payload.Frames = append([]FrameView(nil), payload.Frames...)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func elapsedMS(startedAt time.Time) int64 {
|
||||||
|
if startedAt.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Since(startedAt).Milliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedHeaders(headers map[string][]string) []Header {
|
||||||
|
result := make([]Header, 0, len(headers))
|
||||||
|
for name, values := range headers {
|
||||||
|
value := ""
|
||||||
|
for index, item := range values {
|
||||||
|
if index > 0 {
|
||||||
|
value += ", "
|
||||||
|
}
|
||||||
|
value += item
|
||||||
|
}
|
||||||
|
if isSensitiveHeader(name) && value != "" {
|
||||||
|
value = "[已隐藏]"
|
||||||
|
}
|
||||||
|
result = append(result, Header{Name: name, Value: value})
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(left, right int) bool {
|
||||||
|
return result[left].Name < result[right].Name
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveHeader(name string) bool {
|
||||||
|
switch httpCanonicalLower(name) {
|
||||||
|
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpCanonicalLower(value string) string {
|
||||||
|
buffer := make([]byte, len(value))
|
||||||
|
for index := range value {
|
||||||
|
character := value[index]
|
||||||
|
if character >= 'A' && character <= 'Z' {
|
||||||
|
character += 'a' - 'A'
|
||||||
|
}
|
||||||
|
buffer[index] = character
|
||||||
|
}
|
||||||
|
return string(buffer)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProxyAddr = "127.0.0.1:9090"
|
||||||
|
defaultUIAddr = "127.0.0.1:9091"
|
||||||
|
defaultTargetHost = "api2.cursor.sh"
|
||||||
|
defaultMaxExchanges = 200
|
||||||
|
defaultMaxCaptureBytes = 2 << 20
|
||||||
|
defaultMaxFrames = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config controls the standalone proxy debugger.
|
||||||
|
type Config struct {
|
||||||
|
ProxyAddr string
|
||||||
|
UIAddr string
|
||||||
|
TargetHost string
|
||||||
|
MaxExchanges int
|
||||||
|
MaxCaptureBytes int
|
||||||
|
MaxFrames int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config Config) normalized() Config {
|
||||||
|
if config.ProxyAddr == "" {
|
||||||
|
config.ProxyAddr = defaultProxyAddr
|
||||||
|
}
|
||||||
|
if config.UIAddr == "" {
|
||||||
|
config.UIAddr = defaultUIAddr
|
||||||
|
}
|
||||||
|
if config.TargetHost == "" {
|
||||||
|
config.TargetHost = defaultTargetHost
|
||||||
|
}
|
||||||
|
if config.MaxExchanges <= 0 {
|
||||||
|
config.MaxExchanges = defaultMaxExchanges
|
||||||
|
}
|
||||||
|
if config.MaxCaptureBytes <= 0 {
|
||||||
|
config.MaxCaptureBytes = defaultMaxCaptureBytes
|
||||||
|
}
|
||||||
|
if config.MaxFrames <= 0 {
|
||||||
|
config.MaxFrames = defaultMaxFrames
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeSummary is the compact request-list representation.
|
||||||
|
type ExchangeSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
State string `json:"state"`
|
||||||
|
DurationMS int64 `json:"durationMs"`
|
||||||
|
RequestBytes int64 `json:"requestBytes"`
|
||||||
|
ResponseBytes int64 `json:"responseBytes"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
RequestKind string `json:"requestKind,omitempty"`
|
||||||
|
ResponseKind string `json:"responseKind,omitempty"`
|
||||||
|
FrameCount int `json:"frameCount"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange contains the request and response detail shown by the debugger.
|
||||||
|
type Exchange struct {
|
||||||
|
ExchangeSummary
|
||||||
|
Request Payload `json:"request"`
|
||||||
|
Response Payload `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
|
||||||
|
type Payload struct {
|
||||||
|
Headers []Header `json:"headers"`
|
||||||
|
ContentType string `json:"contentType,omitempty"`
|
||||||
|
ContentCodec string `json:"contentCodec,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
RawHex string `json:"rawHex,omitempty"`
|
||||||
|
RawTruncated bool `json:"rawTruncated,omitempty"`
|
||||||
|
DecodedJSON string `json:"decodedJson,omitempty"`
|
||||||
|
DecodeError string `json:"decodeError,omitempty"`
|
||||||
|
Frames []FrameView `json:"frames,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header is a stable, sorted HTTP header pair.
|
||||||
|
type Header struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameView describes one Connect streaming envelope.
|
||||||
|
type FrameView struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Flags uint8 `json:"flags"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
Compressed bool `json:"compressed"`
|
||||||
|
EndStream bool `json:"endStream"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
MessageType string `json:"messageType,omitempty"`
|
||||||
|
RequestID string `json:"requestId,omitempty"`
|
||||||
|
JSON string `json:"json,omitempty"`
|
||||||
|
RawHex string `json:"rawHex,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storeEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package proxydebugger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cursor/internal/certs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/*
|
||||||
|
var webAssets embed.FS
|
||||||
|
|
||||||
|
func (server *Server) newUIHandler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /api/status", server.handleStatus)
|
||||||
|
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
|
||||||
|
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
|
||||||
|
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
|
||||||
|
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||||
|
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
|
||||||
|
assets, _ := fs.Sub(webAssets, "web")
|
||||||
|
fileServer := http.FileServer(http.FS(assets))
|
||||||
|
mux.Handle("/", fileServer)
|
||||||
|
return securityHeaders(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(writer, http.StatusOK, map[string]any{
|
||||||
|
"proxyAddr": server.config.ProxyAddr,
|
||||||
|
"uiAddr": server.config.UIAddr,
|
||||||
|
"targetHost": server.config.TargetHost,
|
||||||
|
"running": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(writer, http.StatusOK, server.store.summaries())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
id := strings.TrimSpace(request.PathValue("id"))
|
||||||
|
exchange, ok := server.store.get(id)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(writer, http.StatusOK, exchange)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
server.store.clear()
|
||||||
|
writer.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
flusher, ok := writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
writer.Header().Set("Connection", "keep-alive")
|
||||||
|
updates, unsubscribe := server.store.subscribe()
|
||||||
|
defer unsubscribe()
|
||||||
|
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
heartbeat := time.NewTicker(15 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-request.Context().Done():
|
||||||
|
return
|
||||||
|
case event, open := <-updates:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(event)
|
||||||
|
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-heartbeat.C:
|
||||||
|
fmt.Fprint(writer, ": heartbeat\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||||
|
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||||
|
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||||
|
_, _ = writer.Write(certs.EmbeddedCACertPEM())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||||
|
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
writer.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(writer).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func securityHeaders(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
status: null,
|
||||||
|
exchanges: [],
|
||||||
|
selectedId: null,
|
||||||
|
selected: null,
|
||||||
|
search: "",
|
||||||
|
requestId: "",
|
||||||
|
endpoint: "all",
|
||||||
|
sortOrder: "desc",
|
||||||
|
paused: false,
|
||||||
|
pendingRefresh: false,
|
||||||
|
connection: { connected: false, key: "status.connecting", values: {} },
|
||||||
|
tabs: {
|
||||||
|
request: "body",
|
||||||
|
response: "frames",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
statusDot: document.querySelector("#status-dot"),
|
||||||
|
statusText: document.querySelector("#status-text"),
|
||||||
|
proxyAddress: document.querySelector("#proxy-address"),
|
||||||
|
targetHost: document.querySelector("#target-host"),
|
||||||
|
connectionLabel: document.querySelector("#connection-label"),
|
||||||
|
trafficSummary: document.querySelector("#traffic-summary"),
|
||||||
|
searchInput: document.querySelector("#search-input"),
|
||||||
|
requestIdInput: document.querySelector("#request-id-input"),
|
||||||
|
endpointFilter: document.querySelector("#endpoint-filter"),
|
||||||
|
sortOrder: document.querySelector("#sort-order"),
|
||||||
|
requestCount: document.querySelector("#request-count"),
|
||||||
|
requestList: document.querySelector("#request-list"),
|
||||||
|
emptyState: document.querySelector("#empty-state"),
|
||||||
|
selectionSummary: document.querySelector("#selection-summary"),
|
||||||
|
requestContent: document.querySelector("#request-content"),
|
||||||
|
responseContent: document.querySelector("#response-content"),
|
||||||
|
pauseButton: document.querySelector("#pause-button"),
|
||||||
|
clearButton: document.querySelector("#clear-button"),
|
||||||
|
localeSelect: document.querySelector("#locale-select"),
|
||||||
|
workspace: document.querySelector("#workspace"),
|
||||||
|
splitter: document.querySelector("#horizontal-splitter"),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJSON(url, options) {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
state.status = await fetchJSON("/api/status");
|
||||||
|
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
|
||||||
|
renderRuntimeStatus();
|
||||||
|
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
|
||||||
|
elements.targetHost.textContent = state.status.targetHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshList() {
|
||||||
|
state.exchanges = await fetchJSON("/api/exchanges");
|
||||||
|
renderList();
|
||||||
|
renderTrafficSummary();
|
||||||
|
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
|
||||||
|
await refreshDetail(state.selectedId);
|
||||||
|
} else if (state.selectedId) {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selected = null;
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDetail(id) {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
|
||||||
|
if (state.selectedId !== id) return;
|
||||||
|
state.selected = detail;
|
||||||
|
renderDetail();
|
||||||
|
} catch (error) {
|
||||||
|
if (state.selectedId === id) {
|
||||||
|
state.selected = null;
|
||||||
|
renderDetailError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh() {
|
||||||
|
if (state.paused) {
|
||||||
|
state.pendingRefresh = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.pendingRefresh) return;
|
||||||
|
state.pendingRefresh = true;
|
||||||
|
window.setTimeout(async () => {
|
||||||
|
state.pendingRefresh = false;
|
||||||
|
try {
|
||||||
|
await refreshList();
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionState(false, "connection.refreshFailed", { message: error.message });
|
||||||
|
}
|
||||||
|
}, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectEvents() {
|
||||||
|
const events = new EventSource("/api/events");
|
||||||
|
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
|
||||||
|
events.addEventListener("update", scheduleRefresh);
|
||||||
|
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnectionState(connected, key, values = {}) {
|
||||||
|
state.connection = { connected, key, values };
|
||||||
|
renderConnectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuntimeStatus() {
|
||||||
|
if (!state.status) {
|
||||||
|
elements.statusText.textContent = t("status.connecting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnectionState() {
|
||||||
|
const { connected, key, values } = state.connection;
|
||||||
|
elements.connectionLabel.textContent = t(key, values);
|
||||||
|
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredExchanges() {
|
||||||
|
const query = state.search.trim().toLowerCase();
|
||||||
|
const requestId = state.requestId.trim().toLowerCase();
|
||||||
|
const direction = state.sortOrder === "asc" ? 1 : -1;
|
||||||
|
return state.exchanges
|
||||||
|
.filter((item) => {
|
||||||
|
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
||||||
|
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
||||||
|
if (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 renderList() {
|
||||||
|
const exchanges = filteredExchanges();
|
||||||
|
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||||
|
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
|
||||||
|
elements.requestList.innerHTML = exchanges
|
||||||
|
.map((item) => {
|
||||||
|
const selected = item.id === state.selectedId ? " selected" : "";
|
||||||
|
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
|
||||||
|
const kind = item.responseKind || item.requestKind || "-";
|
||||||
|
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
|
||||||
|
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
|
||||||
|
<td><code>${escapeHTML(item.id)}</code></td>
|
||||||
|
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
|
||||||
|
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
|
||||||
|
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
|
||||||
|
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
|
||||||
|
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
|
||||||
|
<td>${formatBytes(item.responseBytes)}</td>
|
||||||
|
<td>${formatDuration(item.durationMs)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrafficSummary() {
|
||||||
|
const totals = state.exchanges.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
result.up += item.requestBytes || 0;
|
||||||
|
result.down += item.responseBytes || 0;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{ up: 0, down: 0 },
|
||||||
|
);
|
||||||
|
elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
if (!state.selected) {
|
||||||
|
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
|
||||||
|
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
|
||||||
|
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = state.selected;
|
||||||
|
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
|
||||||
|
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
|
||||||
|
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
|
||||||
|
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailError(error) {
|
||||||
|
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||||
|
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPayload(payload, tab) {
|
||||||
|
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
|
||||||
|
if (tab === "headers") return renderHeaders(payload.headers);
|
||||||
|
if (tab === "frames") return renderFrames(payload.frames);
|
||||||
|
if (tab === "raw") {
|
||||||
|
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
|
||||||
|
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
|
||||||
|
}
|
||||||
|
if (payload.decodedJson) {
|
||||||
|
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
|
||||||
|
}
|
||||||
|
if (payload.frames?.length) return renderFrames(payload.frames);
|
||||||
|
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
|
||||||
|
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaders(headers = []) {
|
||||||
|
const items = Array.isArray(headers) ? headers : [];
|
||||||
|
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
|
||||||
|
return `<table class="headers-table"><tbody>${items
|
||||||
|
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
|
||||||
|
.join("")}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFrames(frames = []) {
|
||||||
|
const items = Array.isArray(frames) ? frames : [];
|
||||||
|
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
|
||||||
|
return `<div class="frame-list">${items
|
||||||
|
.map((frame) => {
|
||||||
|
const kind = frame.kind || frame.messageType || t("notices.unknown");
|
||||||
|
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
|
||||||
|
const content = frame.json
|
||||||
|
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
|
||||||
|
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
|
||||||
|
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
|
||||||
|
<summary>
|
||||||
|
<span class="frame-index">#${frame.index}</span>
|
||||||
|
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
|
||||||
|
<span class="frame-size">${formatBytes(frame.length)}</span>
|
||||||
|
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
|
||||||
|
</summary>
|
||||||
|
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
|
||||||
|
</details>`;
|
||||||
|
})
|
||||||
|
.join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDecodeError(error) {
|
||||||
|
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTruncated(truncated) {
|
||||||
|
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatState(value) {
|
||||||
|
const key = {
|
||||||
|
pending: "state.pending",
|
||||||
|
streaming: "state.streaming",
|
||||||
|
completed: "state.completed",
|
||||||
|
error: "state.error",
|
||||||
|
}[value];
|
||||||
|
return key ? t(key) : value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentCopyText(side) {
|
||||||
|
const payload = state.selected?.[side];
|
||||||
|
if (!payload) return "";
|
||||||
|
const tab = state.tabs[side];
|
||||||
|
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
|
||||||
|
if (tab === "raw") return payload.rawHex || "";
|
||||||
|
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
|
||||||
|
return payload.decodedJson || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHex(value) {
|
||||||
|
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
|
||||||
|
const lines = [];
|
||||||
|
for (let index = 0; index < hex.length; index += 32) {
|
||||||
|
const chunk = hex.slice(index, index + 32);
|
||||||
|
const bytes = chunk.match(/.{1,2}/g) || [];
|
||||||
|
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const bytes = Number(value || 0);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value) {
|
||||||
|
const milliseconds = Number(value || 0);
|
||||||
|
if (milliseconds < 1000) return `${milliseconds} ms`;
|
||||||
|
return `${(milliseconds / 1000).toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.requestList.addEventListener("click", async (event) => {
|
||||||
|
const row = event.target.closest("tr[data-id]");
|
||||||
|
if (!row) return;
|
||||||
|
state.selectedId = row.dataset.id;
|
||||||
|
state.selected = null;
|
||||||
|
renderList();
|
||||||
|
renderDetail();
|
||||||
|
await refreshDetail(state.selectedId);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.searchInput.addEventListener("input", (event) => {
|
||||||
|
state.search = event.target.value;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.requestIdInput.addEventListener("input", (event) => {
|
||||||
|
state.requestId = event.target.value;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.endpointFilter.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("button[data-value]");
|
||||||
|
if (!button) return;
|
||||||
|
state.endpoint = button.dataset.value;
|
||||||
|
for (const item of elements.endpointFilter.querySelectorAll("button")) {
|
||||||
|
item.classList.toggle("active", item === button);
|
||||||
|
}
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.sortOrder.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("button[data-value]");
|
||||||
|
if (!button) return;
|
||||||
|
state.sortOrder = button.dataset.value;
|
||||||
|
for (const item of elements.sortOrder.querySelectorAll("button")) {
|
||||||
|
item.classList.toggle("active", item === button);
|
||||||
|
}
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".payload-panel").forEach((panel) => {
|
||||||
|
panel.querySelector(".tabs").addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("button[data-tab]");
|
||||||
|
if (!button) return;
|
||||||
|
const side = panel.dataset.side;
|
||||||
|
state.tabs[side] = button.dataset.tab;
|
||||||
|
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
|
||||||
|
renderDetail();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-copy-side]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const text = currentCopyText(button.dataset.copySide);
|
||||||
|
if (!text) return;
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
button.textContent = t("actions.copied");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.textContent = t("actions.copy");
|
||||||
|
}, 900);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPauseState() {
|
||||||
|
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
|
||||||
|
const actionKey = state.paused ? "actions.resume" : "actions.pause";
|
||||||
|
elements.pauseButton.title = t(actionKey);
|
||||||
|
elements.pauseButton.setAttribute("aria-label", t(actionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.pauseButton.addEventListener("click", async () => {
|
||||||
|
state.paused = !state.paused;
|
||||||
|
elements.pauseButton.classList.toggle("active", state.paused);
|
||||||
|
renderPauseState();
|
||||||
|
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
|
||||||
|
if (!state.paused && state.pendingRefresh) {
|
||||||
|
state.pendingRefresh = false;
|
||||||
|
await refreshList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyLocale() {
|
||||||
|
translateDocument();
|
||||||
|
elements.localeSelect.value = getLocale();
|
||||||
|
renderRuntimeStatus();
|
||||||
|
renderConnectionState();
|
||||||
|
renderPauseState();
|
||||||
|
renderList();
|
||||||
|
renderTrafficSummary();
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.localeSelect.addEventListener("change", (event) => {
|
||||||
|
setLocale(event.target.value);
|
||||||
|
applyLocale();
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.clearButton.addEventListener("click", async () => {
|
||||||
|
await fetchJSON("/api/exchanges", { method: "DELETE" });
|
||||||
|
state.selectedId = null;
|
||||||
|
state.selected = null;
|
||||||
|
await refreshList();
|
||||||
|
renderDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
let draggingSplitter = false;
|
||||||
|
elements.splitter.addEventListener("pointerdown", (event) => {
|
||||||
|
draggingSplitter = true;
|
||||||
|
elements.splitter.classList.add("dragging");
|
||||||
|
elements.splitter.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.splitter.addEventListener("pointermove", (event) => {
|
||||||
|
if (!draggingSplitter) return;
|
||||||
|
const bounds = elements.workspace.getBoundingClientRect();
|
||||||
|
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
|
||||||
|
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.splitter.addEventListener("pointerup", () => {
|
||||||
|
draggingSplitter = false;
|
||||||
|
elements.splitter.classList.remove("dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
applyLocale();
|
||||||
|
renderDetail();
|
||||||
|
try {
|
||||||
|
await Promise.all([loadStatus(), refreshList()]);
|
||||||
|
connectEvents();
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionState(false, "connection.connectFailed", { message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const SOURCE_LOCALE = "zh-CN";
|
||||||
|
const DEFAULT_LOCALE = "en-US";
|
||||||
|
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
|
||||||
|
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
"zh-CN": {
|
||||||
|
"app.title": "Cursor 协议调试器",
|
||||||
|
"status.connecting": "正在连接",
|
||||||
|
"status.running": "代理运行中",
|
||||||
|
"status.stopped": "代理已停止",
|
||||||
|
"actions.downloadCA": "下载代理 CA 证书",
|
||||||
|
"actions.caCertificate": "CA 证书",
|
||||||
|
"actions.pause": "暂停界面更新",
|
||||||
|
"actions.resume": "继续界面更新",
|
||||||
|
"actions.clear": "清空",
|
||||||
|
"actions.copy": "复制",
|
||||||
|
"actions.copied": "已复制",
|
||||||
|
"language.label": "界面语言",
|
||||||
|
"filters.region": "请求过滤器",
|
||||||
|
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
|
||||||
|
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||||
|
"filters.endpoint": "接口过滤",
|
||||||
|
"filters.all": "全部",
|
||||||
|
"filters.sort": "排序方向",
|
||||||
|
"filters.ascending": "正序",
|
||||||
|
"filters.descending": "倒序",
|
||||||
|
"count.requests": "{count} 条",
|
||||||
|
"table.url": "网址",
|
||||||
|
"table.message": "消息",
|
||||||
|
"table.method": "方法",
|
||||||
|
"table.status": "状态",
|
||||||
|
"table.response": "响应",
|
||||||
|
"table.duration": "耗时",
|
||||||
|
"empty.waitingForCursor": "等待来自 Cursor 的请求",
|
||||||
|
"splitter.resize": "调整详情区域高度",
|
||||||
|
"selection.waiting": "等待选择",
|
||||||
|
"selection.prompt": "选择一条请求查看详情",
|
||||||
|
"panel.request": "请求",
|
||||||
|
"panel.response": "响应",
|
||||||
|
"panel.requestDetails": "请求详情",
|
||||||
|
"panel.responseDetails": "响应详情",
|
||||||
|
"tabs.headers": "标头",
|
||||||
|
"tabs.body": "正文",
|
||||||
|
"tabs.frames": "帧",
|
||||||
|
"tabs.raw": "原始",
|
||||||
|
"notices.noRequest": "暂无请求内容",
|
||||||
|
"notices.noResponse": "暂无响应内容",
|
||||||
|
"notices.noContent": "暂无内容",
|
||||||
|
"notices.noRaw": "暂无原始数据",
|
||||||
|
"notices.noBody": "暂无可显示的正文",
|
||||||
|
"notices.noHeaders": "暂无标头",
|
||||||
|
"notices.noFrames": "尚未收到完整帧",
|
||||||
|
"notices.unknown": "未识别",
|
||||||
|
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
|
||||||
|
"connection.live": "实时连接中",
|
||||||
|
"connection.retrying": "实时连接正在重试",
|
||||||
|
"connection.refreshFailed": "刷新失败:{message}",
|
||||||
|
"connection.paused": "界面更新已暂停",
|
||||||
|
"connection.connectFailed": "连接失败:{message}",
|
||||||
|
"state.pending": "等待中",
|
||||||
|
"state.streaming": "传输中",
|
||||||
|
"state.completed": "已完成",
|
||||||
|
"state.error": "错误",
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"app.title": "Cursor Protocol Debugger",
|
||||||
|
"status.connecting": "Connecting",
|
||||||
|
"status.running": "Proxy running",
|
||||||
|
"status.stopped": "Proxy stopped",
|
||||||
|
"actions.downloadCA": "Download proxy CA certificate",
|
||||||
|
"actions.caCertificate": "CA Certificate",
|
||||||
|
"actions.pause": "Pause UI updates",
|
||||||
|
"actions.resume": "Resume UI updates",
|
||||||
|
"actions.clear": "Clear",
|
||||||
|
"actions.copy": "Copy",
|
||||||
|
"actions.copied": "Copied",
|
||||||
|
"language.label": "Interface language",
|
||||||
|
"filters.region": "Request filters",
|
||||||
|
"filters.urlPlaceholder": "Filter by URL, message type, or status",
|
||||||
|
"filters.requestIdPlaceholder": "Filter by Request ID",
|
||||||
|
"filters.endpoint": "Endpoint filter",
|
||||||
|
"filters.all": "All",
|
||||||
|
"filters.sort": "Sort order",
|
||||||
|
"filters.ascending": "Oldest first",
|
||||||
|
"filters.descending": "Newest first",
|
||||||
|
"count.requests": "{count} requests",
|
||||||
|
"table.url": "URL",
|
||||||
|
"table.message": "Message",
|
||||||
|
"table.method": "Method",
|
||||||
|
"table.status": "Status",
|
||||||
|
"table.response": "Response",
|
||||||
|
"table.duration": "Duration",
|
||||||
|
"empty.waitingForCursor": "Waiting for requests from Cursor",
|
||||||
|
"splitter.resize": "Resize details area",
|
||||||
|
"selection.waiting": "No selection",
|
||||||
|
"selection.prompt": "Select a request to inspect its details",
|
||||||
|
"panel.request": "Request",
|
||||||
|
"panel.response": "Response",
|
||||||
|
"panel.requestDetails": "Request details",
|
||||||
|
"panel.responseDetails": "Response details",
|
||||||
|
"tabs.headers": "Headers",
|
||||||
|
"tabs.body": "Body",
|
||||||
|
"tabs.frames": "Frames",
|
||||||
|
"tabs.raw": "Raw",
|
||||||
|
"notices.noRequest": "No request content",
|
||||||
|
"notices.noResponse": "No response content",
|
||||||
|
"notices.noContent": "No content",
|
||||||
|
"notices.noRaw": "No raw data",
|
||||||
|
"notices.noBody": "No body available",
|
||||||
|
"notices.noHeaders": "No headers",
|
||||||
|
"notices.noFrames": "No complete frames received yet",
|
||||||
|
"notices.unknown": "Unknown",
|
||||||
|
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
|
||||||
|
"connection.live": "Live connection",
|
||||||
|
"connection.retrying": "Reconnecting live updates",
|
||||||
|
"connection.refreshFailed": "Refresh failed: {message}",
|
||||||
|
"connection.paused": "UI updates paused",
|
||||||
|
"connection.connectFailed": "Connection failed: {message}",
|
||||||
|
"state.pending": "Pending",
|
||||||
|
"state.streaming": "Streaming",
|
||||||
|
"state.completed": "Completed",
|
||||||
|
"state.error": "Error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchLocale(locale) {
|
||||||
|
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
|
||||||
|
if (!normalized) return "";
|
||||||
|
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
|
||||||
|
if (exact) return exact;
|
||||||
|
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialLocale() {
|
||||||
|
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
|
||||||
|
if (stored) return stored;
|
||||||
|
for (const candidate of navigator.languages || [navigator.language]) {
|
||||||
|
const matched = matchLocale(candidate);
|
||||||
|
if (matched) return matched;
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLocale = resolveInitialLocale();
|
||||||
|
|
||||||
|
export function getLocale() {
|
||||||
|
return currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key, values = {}) {
|
||||||
|
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateDocument(root = document) {
|
||||||
|
document.documentElement.lang = currentLocale;
|
||||||
|
document.title = t("app.title");
|
||||||
|
for (const element of root.querySelectorAll("[data-i18n]")) {
|
||||||
|
element.textContent = t(element.dataset.i18n);
|
||||||
|
}
|
||||||
|
for (const [attribute, dataAttribute] of [
|
||||||
|
["aria-label", "i18nAriaLabel"],
|
||||||
|
["placeholder", "i18nPlaceholder"],
|
||||||
|
["title", "i18nTitle"],
|
||||||
|
]) {
|
||||||
|
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
|
||||||
|
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(locale) {
|
||||||
|
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, currentLocale);
|
||||||
|
translateDocument();
|
||||||
|
return currentLocale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Cursor 协议调试器</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
|
<strong data-i18n="app.title">Cursor 协议调试器</strong>
|
||||||
|
</div>
|
||||||
|
<div class="runtime-status" aria-live="polite">
|
||||||
|
<span id="status-dot" class="status-dot"></span>
|
||||||
|
<span id="status-text" data-i18n="status.connecting">正在连接</span>
|
||||||
|
<code id="proxy-address"></code>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="locale-picker">
|
||||||
|
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
|
||||||
|
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
<option value="en-US">EN</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
|
||||||
|
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause">Ⅱ</button>
|
||||||
|
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
|
||||||
|
<div class="search-box url-filter">
|
||||||
|
<span aria-hidden="true">⌕</span>
|
||||||
|
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="search-box request-id-filter">
|
||||||
|
<span aria-hidden="true">⌕</span>
|
||||||
|
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
|
||||||
|
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
|
||||||
|
<button type="button" data-value="runsse">RunSSE</button>
|
||||||
|
<button type="button" data-value="bidiappend">BidiAppend</button>
|
||||||
|
</div>
|
||||||
|
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
|
||||||
|
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
|
||||||
|
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
|
||||||
|
</div>
|
||||||
|
<span id="request-count" class="request-count">0 条</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main id="workspace" class="workspace">
|
||||||
|
<section class="request-list-pane">
|
||||||
|
<table class="request-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="status-column"></th>
|
||||||
|
<th class="index-column">#</th>
|
||||||
|
<th data-i18n="table.url">网址</th>
|
||||||
|
<th class="request-id-column">Request ID</th>
|
||||||
|
<th class="kind-column" data-i18n="table.message">消息</th>
|
||||||
|
<th class="method-column" data-i18n="table.method">方法</th>
|
||||||
|
<th class="code-column" data-i18n="table.status">状态</th>
|
||||||
|
<th class="size-column" data-i18n="table.response">响应</th>
|
||||||
|
<th class="time-column" data-i18n="table.duration">耗时</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="request-list"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
|
||||||
|
|
||||||
|
<section id="detail-pane" class="detail-pane">
|
||||||
|
<div id="selection-summary" class="selection-summary">
|
||||||
|
<span class="method-badge">POST</span>
|
||||||
|
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
|
||||||
|
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
|
||||||
|
</div>
|
||||||
|
<div class="detail-columns">
|
||||||
|
<section class="payload-panel" data-side="request">
|
||||||
|
<div class="panel-header">
|
||||||
|
<strong data-i18n="panel.request">请求</strong>
|
||||||
|
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
|
||||||
|
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
|
||||||
|
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
|
||||||
|
<button type="button" data-tab="frames" data-i18n="tabs.frames">帧</button>
|
||||||
|
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
|
||||||
|
</nav>
|
||||||
|
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
|
||||||
|
</div>
|
||||||
|
<div id="request-content" class="panel-content"></div>
|
||||||
|
</section>
|
||||||
|
<section class="payload-panel" data-side="response">
|
||||||
|
<div class="panel-header">
|
||||||
|
<strong data-i18n="panel.response">响应</strong>
|
||||||
|
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
|
||||||
|
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
|
||||||
|
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
|
||||||
|
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames">帧</button>
|
||||||
|
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
|
||||||
|
</nav>
|
||||||
|
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
|
||||||
|
</div>
|
||||||
|
<div id="response-content" class="panel-content"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
|
||||||
|
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
|
||||||
|
<span id="target-host"></span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,921 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #171818;
|
||||||
|
color: #dedfdd;
|
||||||
|
font-synthesis: none;
|
||||||
|
--surface-0: #171818;
|
||||||
|
--surface-1: #1d1f1f;
|
||||||
|
--surface-2: #242626;
|
||||||
|
--surface-3: #2c2f2f;
|
||||||
|
--border: #343737;
|
||||||
|
--border-strong: #454949;
|
||||||
|
--muted: #8d9390;
|
||||||
|
--text: #dedfdd;
|
||||||
|
--accent: #4ea58b;
|
||||||
|
--accent-soft: #25473d;
|
||||||
|
--cyan: #55a8ba;
|
||||||
|
--orange: #c88762;
|
||||||
|
--danger: #c56d65;
|
||||||
|
--selection: #245b73;
|
||||||
|
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
a {
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--cyan);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
|
||||||
|
min-width: 760px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.filterbar,
|
||||||
|
.statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.runtime-status,
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
gap: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--surface-1);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 240px;
|
||||||
|
color: #bec3c0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status code {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 260px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #7d8380;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: #42bd79;
|
||||||
|
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker select {
|
||||||
|
width: 58px;
|
||||||
|
height: 29px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.icon-button,
|
||||||
|
.copy-button {
|
||||||
|
height: 29px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover,
|
||||||
|
.icon-button:hover,
|
||||||
|
.copy-button:hover {
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger:hover {
|
||||||
|
border-color: #744640;
|
||||||
|
color: #f2b0aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 31px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button.active {
|
||||||
|
border-color: var(--orange);
|
||||||
|
color: #f1bb98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterbar {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 640px;
|
||||||
|
height: 31px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #191b1b;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box > span {
|
||||||
|
padding-left: 9px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 9px;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder {
|
||||||
|
color: #6f7572;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-filter {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-filter {
|
||||||
|
flex: 0 1 320px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
height: 31px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
min-width: 62px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #1b1d1d;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: #bce8d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-control button {
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-list-pane {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #181a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #202222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table th,
|
||||||
|
.request-table td {
|
||||||
|
height: 30px;
|
||||||
|
border-right: 1px solid #2c2f2f;
|
||||||
|
border-bottom: 1px solid #292c2c;
|
||||||
|
padding: 0 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table th {
|
||||||
|
color: #9da29f;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table tbody tr {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table tbody tr:hover {
|
||||||
|
background: #222525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table tbody tr.selected {
|
||||||
|
background: var(--selection);
|
||||||
|
color: #f3f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-column {
|
||||||
|
width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-column {
|
||||||
|
width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-column {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-column {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-column {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-column {
|
||||||
|
width: 66px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-column {
|
||||||
|
width: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-column {
|
||||||
|
width: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-state {
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #7c8380;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-state.streaming {
|
||||||
|
background: #45ba77;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-state.completed {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-state.error {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-text {
|
||||||
|
color: #61b9df;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.success {
|
||||||
|
color: #68c991;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.error {
|
||||||
|
color: #e18b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-text {
|
||||||
|
color: #d3a17f;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-text {
|
||||||
|
color: #8bc2cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 34px 0 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #686e6b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-splitter {
|
||||||
|
cursor: row-resize;
|
||||||
|
background: #343737;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-splitter:hover,
|
||||||
|
.horizontal-splitter.dragging {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-pane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 38px minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #1b1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary code {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #aeb4b1;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge,
|
||||||
|
.status-badge,
|
||||||
|
.frame-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
border-color: #34667a;
|
||||||
|
color: #74c8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.success {
|
||||||
|
border-color: #3f7157;
|
||||||
|
color: #83d5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 36px minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-panel:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #202222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header > strong {
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #c9cdca;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
position: relative;
|
||||||
|
min-width: 46px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button:hover {
|
||||||
|
color: #d7dad8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
color: #71c7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 8px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--cyan);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 7px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #181a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-view,
|
||||||
|
.hex-view {
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px 30px;
|
||||||
|
color: #ccd1ce;
|
||||||
|
font: 11px/1.55 var(--mono);
|
||||||
|
tab-size: 2;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hex-view {
|
||||||
|
color: #b6c2bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font: 11px/1.4 var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-table th,
|
||||||
|
.headers-table td {
|
||||||
|
border-bottom: 1px solid #292c2c;
|
||||||
|
padding: 7px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-table th {
|
||||||
|
width: 38%;
|
||||||
|
color: #62b3cd;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-table td {
|
||||||
|
color: #c5c9c6;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-list {
|
||||||
|
min-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item {
|
||||||
|
border-bottom: 1px solid #292c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #c5cac7;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 11px var(--mono);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item summary:hover {
|
||||||
|
background: #222525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item[open] summary {
|
||||||
|
background: #242727;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-index {
|
||||||
|
color: #747b77;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-kind {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #dfaa85;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-size,
|
||||||
|
.frame-flags {
|
||||||
|
color: #7faeb7;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-error {
|
||||||
|
margin: 10px 14px;
|
||||||
|
color: #ec968e;
|
||||||
|
font: 11px/1.5 var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 14px;
|
||||||
|
color: #7d8581;
|
||||||
|
font: 12px/1.6 var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error {
|
||||||
|
color: #df8b83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncated-notice {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-top: 1px solid #674f3f;
|
||||||
|
background: #3d3028;
|
||||||
|
color: #e5b28e;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: #848b87;
|
||||||
|
font: 10px var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar span:first-child {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterbar {
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-filter,
|
||||||
|
.request-id-filter {
|
||||||
|
flex: 1 1 300px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status code,
|
||||||
|
.kind-column,
|
||||||
|
.request-table td:nth-child(5) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-panel {
|
||||||
|
min-height: 260px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: relative;
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status {
|
||||||
|
order: 3;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-status code {
|
||||||
|
display: block;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions .button {
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterbar {
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-filter,
|
||||||
|
.request-id-filter {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-count {
|
||||||
|
order: 5;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endpoint-filter {
|
||||||
|
order: 3;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-control {
|
||||||
|
order: 4;
|
||||||
|
flex: 0 0 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table th,
|
||||||
|
.request-table td {
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-table .index-column,
|
||||||
|
.request-table th:nth-child(2),
|
||||||
|
.request-table td:nth-child(2),
|
||||||
|
.size-column,
|
||||||
|
.request-table th:nth-child(8),
|
||||||
|
.request-table td:nth-child(8),
|
||||||
|
.time-column,
|
||||||
|
.request-table th:nth-child(9),
|
||||||
|
.request-table td:nth-child(9) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-id-column {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-column {
|
||||||
|
width: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-column {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-columns {
|
||||||
|
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header > strong {
|
||||||
|
width: 72px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
width: 42px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-list {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-item summary {
|
||||||
|
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#target-host {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
cursor-tab-server-linux-amd64.tar
|
||||||
|
*.tar
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:1.25 AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
ENV GOPROXY=${GOPROXY}
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/cursor-tab-server .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
|
COPY --from=build /out/cursor-tab-server /app/cursor-tab-server
|
||||||
|
COPY config.yaml /app/config.yaml
|
||||||
|
|
||||||
|
EXPOSE 8041
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/cursor-tab-server"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
### 如何获取 token?
|
||||||
|
**macos**
|
||||||
|
```bash
|
||||||
|
sqlite3 "$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb" \
|
||||||
|
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
|
||||||
|
|
||||||
|
```
|
||||||
|
**windows 获取方式**
|
||||||
|
```bash
|
||||||
|
sqlite3 "$env:APPDATA\Cursor\User\globalStorage\state.vscdb" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
token: "eyJ..."
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module cursor-tab-server
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultConfigPath = "./config.yaml"
|
||||||
|
defaultListenAddr = ":8041"
|
||||||
|
)
|
||||||
|
|
||||||
|
var hopByHopHeaders = map[string]struct{}{
|
||||||
|
"connection": {},
|
||||||
|
"proxy-connection": {},
|
||||||
|
"keep-alive": {},
|
||||||
|
"proxy-authenticate": {},
|
||||||
|
"proxy-authorization": {},
|
||||||
|
"te": {},
|
||||||
|
"trailer": {},
|
||||||
|
"transfer-encoding": {},
|
||||||
|
"upgrade": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultUpstreamTargets = map[string]string{
|
||||||
|
"/aiserver.v1.AiService/StreamCpp": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamCpp",
|
||||||
|
"/aiserver.v1.AiService/StreamNextCursorPrediction": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamNextCursorPrediction",
|
||||||
|
"/aiserver.v1.AiService/GetCppEditClassification": "https://api4.cursor.sh:443/aiserver.v1.AiService/GetCppEditClassification",
|
||||||
|
"/aiserver.v1.AiService/RefreshTabContext": "https://api2.cursor.sh:443/aiserver.v1.AiService/RefreshTabContext",
|
||||||
|
"/aiserver.v1.AiService/CppConfig": "https://api4.cursor.sh:443/aiserver.v1.AiService/CppConfig",
|
||||||
|
"/aiserver.v1.AiService/CppEditHistoryStatus": "https://api2.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryStatus",
|
||||||
|
"/aiserver.v1.AiService/CppAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppAppend",
|
||||||
|
"/aiserver.v1.AiService/CppEditHistoryAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryAppend",
|
||||||
|
"/aiserver.v1.CppService/AvailableModels": "https://api3.cursor.sh:443/aiserver.v1.CppService/AvailableModels",
|
||||||
|
"/aiserver.v1.CppService/RecordCppFate": "https://api2.cursor.sh:443/aiserver.v1.CppService/RecordCppFate",
|
||||||
|
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics": "https://api2.cursor.sh:443/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
|
||||||
|
"/aiserver.v1.AiService/WriteGitCommitMessage": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitCommitMessage",
|
||||||
|
"/aiserver.v1.AiService/WriteGitBranchName": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitBranchName",
|
||||||
|
"/aiserver.v1.FileSyncService/FSSyncFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSSyncFile",
|
||||||
|
"/aiserver.v1.FileSyncService/FSIsEnabledForUser": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSIsEnabledForUser",
|
||||||
|
"/aiserver.v1.FileSyncService/FSConfig": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSConfig",
|
||||||
|
"/aiserver.v1.FileSyncService/FSUploadFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSUploadFile",
|
||||||
|
"/aiserver.v1.DashboardService/GetEffectiveUserPlugins": "https://api2.cursor.sh:443/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
|
||||||
|
}
|
||||||
|
|
||||||
|
type appConfig struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverApp struct {
|
||||||
|
config appConfig
|
||||||
|
client *http.Client
|
||||||
|
upstreamTargets map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := loadConfig(defaultConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Printf("cursor-tab-server 启动 listen_addr=%s config_path=%s", defaultListenAddr, defaultConfigPath)
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: defaultListenAddr,
|
||||||
|
Handler: newServerApp(cfg, newHTTPClient(), defaultUpstreamTargets),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "监听失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string]string) http.Handler {
|
||||||
|
app := &serverApp{
|
||||||
|
config: cfg,
|
||||||
|
client: client,
|
||||||
|
upstreamTargets: cloneUpstreamTargets(upstreamTargets),
|
||||||
|
}
|
||||||
|
if app.client == nil {
|
||||||
|
app.client = newHTTPClient()
|
||||||
|
}
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if err := app.handleProxy(writer, request); err != nil {
|
||||||
|
http.Error(writer, err.Error(), http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *serverApp) handleProxy(writer http.ResponseWriter, request *http.Request) error {
|
||||||
|
if app == nil {
|
||||||
|
return fmt.Errorf("服务实例为空")
|
||||||
|
}
|
||||||
|
rawTarget, ok := app.upstreamTargets[strings.TrimSpace(request.URL.Path)]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targetURL, err := url.Parse(rawTarget)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析上游地址失败: %w", err)
|
||||||
|
}
|
||||||
|
targetURL.RawQuery = request.URL.RawQuery
|
||||||
|
|
||||||
|
requestBody := []byte{}
|
||||||
|
if shouldRequestCarryBody(request.Method) {
|
||||||
|
requestBody, err = io.ReadAll(request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取请求体失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamRequest, err := http.NewRequestWithContext(request.Context(), request.Method, targetURL.String(), bytes.NewReader(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("构建上游请求失败: %w", err)
|
||||||
|
}
|
||||||
|
copyRequestHeaders(upstreamRequest.Header, request.Header)
|
||||||
|
authorization := formatBearerAuthorization(app.config.Token)
|
||||||
|
upstreamRequest.Header.Set("Authorization", authorization)
|
||||||
|
upstreamRequest.Header.Set("x-cursor-checksum", buildCursorChecksum(authorization))
|
||||||
|
if !shouldRequestCarryBody(request.Method) {
|
||||||
|
upstreamRequest.Header.Del("content-length")
|
||||||
|
} else {
|
||||||
|
upstreamRequest.Header.Set("content-length", strconv.Itoa(len(requestBody)))
|
||||||
|
}
|
||||||
|
upstreamRequest.Host = targetURL.Host
|
||||||
|
|
||||||
|
response, err := app.client.Do(upstreamRequest)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("上游转发失败 method=%s path=%s target=%s err=%v", request.Method, request.URL.Path, targetURL.String(), err)
|
||||||
|
return fmt.Errorf("上游请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
log.Printf("上游响应 method=%s path=%s target_host=%s status=%d", request.Method, request.URL.Path, targetURL.Host, response.StatusCode)
|
||||||
|
|
||||||
|
copyResponseHeaders(writer.Header(), response.Header)
|
||||||
|
writer.WriteHeader(response.StatusCode)
|
||||||
|
_, err = copyStream(writer, response.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (appConfig, error) {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return appConfig{}, err
|
||||||
|
}
|
||||||
|
token, err := parseTokenYAML(contents)
|
||||||
|
if err != nil {
|
||||||
|
return appConfig{}, err
|
||||||
|
}
|
||||||
|
return appConfig{Token: token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTokenYAML(contents []byte) (string, error) {
|
||||||
|
var cfg appConfig
|
||||||
|
if err := yaml.Unmarshal(contents, &cfg); err != nil {
|
||||||
|
return "", fmt.Errorf("解析配置失败: %w", err)
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(cfg.Token)
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("token 不能为空")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyRequestHeaders(target http.Header, source http.Header) {
|
||||||
|
for key, values := range source {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
if _, exists := hopByHopHeaders[lowerKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
target.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyResponseHeaders(target http.Header, source http.Header) {
|
||||||
|
for key, values := range source {
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
if _, exists := hopByHopHeaders[lowerKey]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
target.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStream(writer io.Writer, reader io.Reader) (int64, error) {
|
||||||
|
buffer := make([]byte, 32*1024)
|
||||||
|
var total int64
|
||||||
|
for {
|
||||||
|
readCount, readErr := reader.Read(buffer)
|
||||||
|
if readCount > 0 {
|
||||||
|
chunk := buffer[:readCount]
|
||||||
|
written, writeErr := writer.Write(chunk)
|
||||||
|
total += int64(written)
|
||||||
|
if writeErr != nil {
|
||||||
|
return total, writeErr
|
||||||
|
}
|
||||||
|
if written < len(chunk) {
|
||||||
|
return total, io.ErrShortWrite
|
||||||
|
}
|
||||||
|
if flusher, ok := writer.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
return total, readErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRequestCarryBody(method string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(method)) {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodDelete:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBearerAuthorization(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "Bearer " + value
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCursorChecksum(authorization string) string {
|
||||||
|
const (
|
||||||
|
checksumTimestampDivisor = 1_000_000
|
||||||
|
checksumInitialSeed = 165
|
||||||
|
)
|
||||||
|
timestamp := time.Now().UnixMilli() / checksumTimestampDivisor
|
||||||
|
timestampBytes := make([]byte, 6)
|
||||||
|
timestampBigInt := big.NewInt(timestamp)
|
||||||
|
for index := 0; index < len(timestampBytes); index++ {
|
||||||
|
shift := uint((len(timestampBytes) - 1 - index) * 8)
|
||||||
|
timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff)
|
||||||
|
}
|
||||||
|
seed := checksumInitialSeed
|
||||||
|
for index := 0; index < len(timestampBytes); index++ {
|
||||||
|
current := int(timestampBytes[index]^byte(seed)) + (index % 256)
|
||||||
|
current &= 0xff
|
||||||
|
timestampBytes[index] = byte(current)
|
||||||
|
seed = current
|
||||||
|
}
|
||||||
|
prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=")
|
||||||
|
hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization)))
|
||||||
|
hash := fmt.Sprintf("%x", hashBytes)
|
||||||
|
return prefix + hash[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient() *http.Client {
|
||||||
|
return &http.Client{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneUpstreamTargets(input map[string]string) map[string]string {
|
||||||
|
output := make(map[string]string, len(input))
|
||||||
|
for key, value := range input {
|
||||||
|
output[key] = value
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
registry "https://registry.npmmirror.com"
|
||||||
|
network-timeout 120000
|
||||||
@@ -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助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@bindings/*": ["bindings/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build:dev": "node ./scripts/run-vite-build.mjs --scan --minify false --mode development",
|
||||||
|
"build": "node ./scripts/run-vite-build.mjs --scan --mode production",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.4",
|
||||||
|
"@iconify/json": "^2.2.447",
|
||||||
|
"@wailsio/runtime": "latest",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"copy-text-to-clipboard": "^3.2.2",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/tailwind": "^1.2.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"code-inspector-plugin": "^1.4.3",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^7.1.11",
|
||||||
|
"vite-plugin-top-level-await": "^1.6.0"
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"Safari >= 13"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import MagicString from "magic-string";
|
||||||
|
import { parse as parseJavaScript } from "@babel/parser";
|
||||||
|
import traverseModule from "@babel/traverse";
|
||||||
|
import { parse as parseTemplate } from "@vue/compiler-dom";
|
||||||
|
import { parse as parseSFC } from "@vue/compiler-sfc";
|
||||||
|
import { normalizePath } from "vite";
|
||||||
|
|
||||||
|
const traverse = traverseModule.default ?? traverseModule;
|
||||||
|
|
||||||
|
const SOURCE_LANGUAGE = "zh-CN";
|
||||||
|
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP", "ru-RU"];
|
||||||
|
const HAN_REGEX = /\p{Script=Han}/u;
|
||||||
|
const JS_HELPERS = {
|
||||||
|
localized: "__i18nLocalized",
|
||||||
|
localizedTemplate: "__i18nLocalizedTemplate",
|
||||||
|
};
|
||||||
|
const TEMPLATE_HELPERS = {
|
||||||
|
localized: "$ls",
|
||||||
|
localizedTemplate: "$lt",
|
||||||
|
};
|
||||||
|
const RUNTIME_IMPORT = "@/i18n/runtime";
|
||||||
|
const BABEL_PLUGINS = [
|
||||||
|
"jsx",
|
||||||
|
"typescript",
|
||||||
|
"classProperties",
|
||||||
|
"classPrivateProperties",
|
||||||
|
"classPrivateMethods",
|
||||||
|
"topLevelAwait",
|
||||||
|
"importAttributes",
|
||||||
|
];
|
||||||
|
|
||||||
|
function containsHan(value) {
|
||||||
|
return typeof value === "string" && HAN_REGEX.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJSONLiteral(value) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingleQuotedLiteral(value) {
|
||||||
|
return `'${String(value)
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\u2028/g, "\\u2028")
|
||||||
|
.replace(/\u2029/g, "\\u2029")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashMessageID(message) {
|
||||||
|
return createHash("sha256").update(message).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuery(id) {
|
||||||
|
return id.split("?")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceFile(id) {
|
||||||
|
const cleanID = stripQuery(id);
|
||||||
|
return /\.(?:js|jsx|ts|tsx|vue)$/.test(cleanID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExcludedFile(rootDir, id) {
|
||||||
|
const cleanID = normalizePath(stripQuery(id));
|
||||||
|
if (cleanID.includes("/node_modules/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = normalizePath(path.relative(rootDir, cleanID));
|
||||||
|
if (!relativePath.startsWith("src/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativePath.startsWith("src/i18n/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJSONFile(filePath, fallback) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDirectory(filePath) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJSONFile(filePath, payload) {
|
||||||
|
ensureDirectory(filePath);
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRef(filePath, rootDir, loc) {
|
||||||
|
return {
|
||||||
|
file: normalizePath(path.relative(rootDir, filePath)),
|
||||||
|
line: loc?.line ?? 1,
|
||||||
|
column: loc?.column ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageRecord(filePath, rootDir, canonical, placeholders, loc) {
|
||||||
|
const id = hashMessageID(canonical);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source: canonical,
|
||||||
|
kind: placeholders > 0 ? "template" : "text",
|
||||||
|
placeholders,
|
||||||
|
ref: buildRef(filePath, rootDir, loc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessageRecords(records) {
|
||||||
|
const entries = new Map();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const current = entries.get(record.id);
|
||||||
|
if (!current) {
|
||||||
|
entries.set(record.id, {
|
||||||
|
source: record.source,
|
||||||
|
kind: record.kind,
|
||||||
|
placeholders: record.placeholders,
|
||||||
|
refs: [record.ref],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.source !== record.source) {
|
||||||
|
throw new Error(
|
||||||
|
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
current.refs.push(record.ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEntries = {};
|
||||||
|
for (const id of Array.from(entries.keys()).sort()) {
|
||||||
|
const entry = entries.get(id);
|
||||||
|
sortedEntries[id] = {
|
||||||
|
source: entry.source,
|
||||||
|
kind: entry.kind,
|
||||||
|
placeholders: entry.placeholders,
|
||||||
|
refs: entry.refs.sort((left, right) =>
|
||||||
|
left.file.localeCompare(right.file) ||
|
||||||
|
left.line - right.line ||
|
||||||
|
left.column - right.column),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries: sortedEntries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeLocaleMessages(existingMessages, catalogEntries, locale) {
|
||||||
|
const nextMessages = {};
|
||||||
|
|
||||||
|
for (const id of Object.keys(catalogEntries)) {
|
||||||
|
if (locale === SOURCE_LANGUAGE) {
|
||||||
|
nextMessages[id] = catalogEntries[id].source;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = existingMessages?.[id];
|
||||||
|
nextMessages[id] = typeof currentValue === "string" ? currentValue : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkSourceFiles(dirPath, visitor) {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nextPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
walkSourceFiles(nextPath, visitor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitor(nextPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProgram(code, filename) {
|
||||||
|
try {
|
||||||
|
return parseJavaScript(code, {
|
||||||
|
sourceType: "module",
|
||||||
|
sourceFilename: filename,
|
||||||
|
plugins: BABEL_PLUGINS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`[static-i18n] Failed to parse ${filename}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplateCanonical(node) {
|
||||||
|
const parts = [];
|
||||||
|
for (let index = 0; index < node.quasis.length; index += 1) {
|
||||||
|
const quasi = node.quasis[index];
|
||||||
|
parts.push(quasi.value.cooked ?? quasi.value.raw ?? "");
|
||||||
|
if (index < node.expressions.length) {
|
||||||
|
parts.push(`{${index}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreStringLiteral(path) {
|
||||||
|
const parent = path.parentPath;
|
||||||
|
if (!parent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parent.isImportDeclaration() ||
|
||||||
|
parent.isExportAllDeclaration() ||
|
||||||
|
parent.isExportNamedDeclaration()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.isDirective()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(parent.isObjectProperty() || parent.isObjectMethod()) &&
|
||||||
|
path.key === "key" &&
|
||||||
|
parent.node.computed !== true
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(parent.isMemberExpression() || parent.isOptionalMemberExpression?.()) &&
|
||||||
|
path.key === "property" &&
|
||||||
|
parent.node.computed !== true
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(parent.isClassMethod?.() || parent.isClassProperty?.() || parent.isClassPrivateProperty?.()) &&
|
||||||
|
path.key === "key"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreTemplateLiteral(path) {
|
||||||
|
return path.parentPath?.isTaggedTemplateExpression?.() === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStringLiteralReplacement(path, helperNames, record, quoteLiteral) {
|
||||||
|
if (path.parentPath?.isJSXAttribute?.() && path.key === "value") {
|
||||||
|
return `{${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral) {
|
||||||
|
if (path.node.expressions.length === 0) {
|
||||||
|
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = path.node.expressions.map((expression) => code.slice(expression.start, expression.end));
|
||||||
|
const payload = `[${args.join(", ")}]`;
|
||||||
|
return `${helperNames.localizedTemplate}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)}, ${payload})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectJSReplacements(code, ast, filePath, rootDir, helperNames, options = {}) {
|
||||||
|
const replacements = [];
|
||||||
|
const records = [];
|
||||||
|
const helperUsage = {
|
||||||
|
localized: false,
|
||||||
|
localizedTemplate: false,
|
||||||
|
};
|
||||||
|
const refLoc = options.refLoc ?? null;
|
||||||
|
const quoteLiteral = options.quoteLiteral ?? toJSONLiteral;
|
||||||
|
|
||||||
|
function resolveLoc(node) {
|
||||||
|
if (refLoc) {
|
||||||
|
return refLoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node?.loc?.start
|
||||||
|
? {
|
||||||
|
line: node.loc.start.line,
|
||||||
|
column: node.loc.start.column + 1,
|
||||||
|
}
|
||||||
|
: { line: 1, column: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(ast, {
|
||||||
|
noScope: true,
|
||||||
|
StringLiteral(path) {
|
||||||
|
if (shouldIgnoreStringLiteral(path) || !containsHan(path.node.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildMessageRecord(filePath, rootDir, path.node.value, 0, resolveLoc(path.node));
|
||||||
|
records.push(record);
|
||||||
|
helperUsage.localized = true;
|
||||||
|
replacements.push({
|
||||||
|
start: path.node.start,
|
||||||
|
end: path.node.end,
|
||||||
|
text: createStringLiteralReplacement(path, helperNames, record, quoteLiteral),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TemplateLiteral(path) {
|
||||||
|
if (shouldIgnoreTemplateLiteral(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = createTemplateCanonical(path.node);
|
||||||
|
if (!containsHan(canonical)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = buildMessageRecord(
|
||||||
|
filePath,
|
||||||
|
rootDir,
|
||||||
|
canonical,
|
||||||
|
path.node.expressions.length,
|
||||||
|
resolveLoc(path.node),
|
||||||
|
);
|
||||||
|
records.push(record);
|
||||||
|
if (path.node.expressions.length === 0) {
|
||||||
|
helperUsage.localized = true;
|
||||||
|
} else {
|
||||||
|
helperUsage.localizedTemplate = true;
|
||||||
|
}
|
||||||
|
replacements.push({
|
||||||
|
start: path.node.start,
|
||||||
|
end: path.node.end,
|
||||||
|
text: createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
replacements,
|
||||||
|
records,
|
||||||
|
helperUsage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReplacements(code, replacements) {
|
||||||
|
if (!replacements.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const magicString = new MagicString(code);
|
||||||
|
const sortedReplacements = [...replacements].sort((left, right) => right.start - left.start);
|
||||||
|
for (const replacement of sortedReplacements) {
|
||||||
|
magicString.overwrite(replacement.start, replacement.end, replacement.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return magicString;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRuntimeImport(code, helperUsage) {
|
||||||
|
if (!helperUsage.localized && !helperUsage.localizedTemplate) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieces = [];
|
||||||
|
if (helperUsage.localized) {
|
||||||
|
pieces.push(`localized as ${JS_HELPERS.localized}`);
|
||||||
|
}
|
||||||
|
if (helperUsage.localizedTemplate) {
|
||||||
|
pieces.push(`localizedTemplate as ${JS_HELPERS.localizedTemplate}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `import { ${pieces.join(", ")} } from "${RUNTIME_IMPORT}";\n${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformJavaScript(code, filePath, rootDir, options = {}) {
|
||||||
|
const ast = parseProgram(code, filePath);
|
||||||
|
const result = collectJSReplacements(
|
||||||
|
code,
|
||||||
|
ast,
|
||||||
|
filePath,
|
||||||
|
rootDir,
|
||||||
|
options.helperNames ?? JS_HELPERS,
|
||||||
|
{
|
||||||
|
refLoc: options.refLoc,
|
||||||
|
quoteLiteral: options.quoteLiteral,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const magicString = applyReplacements(code, result.replacements);
|
||||||
|
const transformedCode = options.injectImport === false
|
||||||
|
? magicString?.toString() ?? code
|
||||||
|
: ensureRuntimeImport(magicString?.toString() ?? code, result.helperUsage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: transformedCode,
|
||||||
|
changed: transformedCode !== code,
|
||||||
|
records: result.records,
|
||||||
|
map: magicString
|
||||||
|
? magicString.generateMap({
|
||||||
|
source: filePath,
|
||||||
|
hires: true,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateTemplateExpression(expression, filePath, rootDir, refLoc) {
|
||||||
|
const wrappedCode = `(${expression})`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transformed = transformJavaScript(wrappedCode, filePath, rootDir, {
|
||||||
|
helperNames: TEMPLATE_HELPERS,
|
||||||
|
injectImport: false,
|
||||||
|
refLoc,
|
||||||
|
quoteLiteral: toSingleQuotedLiteral,
|
||||||
|
});
|
||||||
|
const nextCode = transformed.code.slice(1, -1);
|
||||||
|
return {
|
||||||
|
code: nextCode,
|
||||||
|
changed: nextCode !== expression,
|
||||||
|
records: transformed.records,
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
const transformed = transformJavaScript(expression, filePath, rootDir, {
|
||||||
|
helperNames: TEMPLATE_HELPERS,
|
||||||
|
injectImport: false,
|
||||||
|
refLoc,
|
||||||
|
quoteLiteral: toSingleQuotedLiteral,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: transformed.code,
|
||||||
|
changed: transformed.code !== expression,
|
||||||
|
records: transformed.records,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextNodeReplacement(source, record) {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadingLength = source.indexOf(trimmed);
|
||||||
|
const leading = leadingLength > 0 ? source.slice(0, leadingLength) : "";
|
||||||
|
const trailing = source.slice(leadingLength + trimmed.length);
|
||||||
|
return `${leading}{{ $ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)}) }}${trailing}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkTemplateNode(node, visitor) {
|
||||||
|
visitor(node);
|
||||||
|
|
||||||
|
if (Array.isArray(node.branches)) {
|
||||||
|
node.branches.forEach((branch) => walkTemplateNode(branch, visitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children.forEach((child) => walkTemplateNode(child, visitor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 1 && Array.isArray(node.props)) {
|
||||||
|
for (const prop of node.props) {
|
||||||
|
visitor(prop, node);
|
||||||
|
if (prop.exp) {
|
||||||
|
visitor(prop.exp, prop);
|
||||||
|
}
|
||||||
|
if (prop.arg) {
|
||||||
|
visitor(prop.arg, prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 5 && node.content) {
|
||||||
|
visitor(node.content, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAbsoluteLoc(sourceCode, sourceOffset, relativeOffset) {
|
||||||
|
const absoluteOffset = sourceOffset + relativeOffset;
|
||||||
|
const prefix = sourceCode.slice(0, absoluteOffset);
|
||||||
|
const lastLineBreak = prefix.lastIndexOf("\n");
|
||||||
|
return {
|
||||||
|
line: prefix.split("\n").length,
|
||||||
|
column: absoluteOffset - lastLineBreak,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformVueTemplate(templateCode, filePath, rootDir, options = {}) {
|
||||||
|
const ast = parseTemplate(templateCode, { comments: true });
|
||||||
|
const replacements = [];
|
||||||
|
const records = [];
|
||||||
|
const sourceCode = options.sourceCode ?? templateCode;
|
||||||
|
const sourceOffset = options.sourceOffset ?? 0;
|
||||||
|
const resolveLoc = (node, offset = 0) =>
|
||||||
|
resolveAbsoluteLoc(sourceCode, sourceOffset, node.loc.start.offset + offset);
|
||||||
|
|
||||||
|
walkTemplateNode(ast, (node, parent) => {
|
||||||
|
if (node.type === 2 && containsHan(node.content)) {
|
||||||
|
const canonical = node.content.trim();
|
||||||
|
const record = buildMessageRecord(
|
||||||
|
filePath,
|
||||||
|
rootDir,
|
||||||
|
canonical,
|
||||||
|
0,
|
||||||
|
resolveLoc(node, node.content.indexOf(canonical)),
|
||||||
|
);
|
||||||
|
const replacement = createTextNodeReplacement(node.loc.source, record);
|
||||||
|
if (!replacement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(record);
|
||||||
|
replacements.push({
|
||||||
|
start: node.loc.start.offset,
|
||||||
|
end: node.loc.end.offset,
|
||||||
|
text: replacement,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 6 && node.value && containsHan(node.value.content)) {
|
||||||
|
const record = buildMessageRecord(
|
||||||
|
filePath,
|
||||||
|
rootDir,
|
||||||
|
node.value.content,
|
||||||
|
0,
|
||||||
|
resolveLoc(node),
|
||||||
|
);
|
||||||
|
records.push(record);
|
||||||
|
replacements.push({
|
||||||
|
start: node.loc.start.offset,
|
||||||
|
end: node.loc.end.offset,
|
||||||
|
text: `:${node.name}="$ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)})"`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type === 4 &&
|
||||||
|
typeof node.content === "string" &&
|
||||||
|
containsHan(node.content) &&
|
||||||
|
parent &&
|
||||||
|
((parent.type === 5) || (parent.type === 7 && parent.exp === node))
|
||||||
|
) {
|
||||||
|
const transformed = translateTemplateExpression(
|
||||||
|
node.content,
|
||||||
|
filePath,
|
||||||
|
rootDir,
|
||||||
|
resolveLoc(node),
|
||||||
|
);
|
||||||
|
if (!transformed.changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
records.push(...transformed.records);
|
||||||
|
replacements.push({
|
||||||
|
start: node.loc.start.offset,
|
||||||
|
end: node.loc.end.offset,
|
||||||
|
text: transformed.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const magicString = applyReplacements(templateCode, replacements);
|
||||||
|
return {
|
||||||
|
code: magicString?.toString() ?? templateCode,
|
||||||
|
changed: Boolean(magicString),
|
||||||
|
records,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformVueSFC(code, filePath, rootDir) {
|
||||||
|
const { descriptor } = parseSFC(code, { filename: filePath });
|
||||||
|
const magicString = new MagicString(code);
|
||||||
|
const records = [];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (descriptor.template) {
|
||||||
|
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir, {
|
||||||
|
sourceCode: code,
|
||||||
|
sourceOffset: descriptor.template.loc.start.offset,
|
||||||
|
});
|
||||||
|
records.push(...templateResult.records);
|
||||||
|
if (templateResult.changed) {
|
||||||
|
changed = true;
|
||||||
|
magicString.overwrite(
|
||||||
|
descriptor.template.loc.start.offset,
|
||||||
|
descriptor.template.loc.end.offset,
|
||||||
|
templateResult.code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of [descriptor.script, descriptor.scriptSetup].filter(Boolean)) {
|
||||||
|
const scriptResult = transformJavaScript(block.content, filePath, rootDir, {
|
||||||
|
helperNames: JS_HELPERS,
|
||||||
|
injectImport: true,
|
||||||
|
});
|
||||||
|
records.push(...scriptResult.records);
|
||||||
|
if (scriptResult.changed) {
|
||||||
|
changed = true;
|
||||||
|
magicString.overwrite(block.loc.start.offset, block.loc.end.offset, scriptResult.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: changed ? magicString.toString() : code,
|
||||||
|
changed,
|
||||||
|
records,
|
||||||
|
map: changed
|
||||||
|
? magicString.generateMap({
|
||||||
|
source: filePath,
|
||||||
|
hires: true,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformSourceCode(code, filePath, rootDir) {
|
||||||
|
if (filePath.endsWith(".vue")) {
|
||||||
|
return transformVueSFC(code, filePath, rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformJavaScript(code, filePath, rootDir, {
|
||||||
|
helperNames: JS_HELPERS,
|
||||||
|
injectImport: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCatalogRecords(rootDir) {
|
||||||
|
const srcDir = path.join(rootDir, "src");
|
||||||
|
const records = [];
|
||||||
|
|
||||||
|
walkSourceFiles(srcDir, (filePath) => {
|
||||||
|
const cleanPath = normalizePath(filePath);
|
||||||
|
if (!isSourceFile(cleanPath) || isExcludedFile(rootDir, cleanPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = fs.readFileSync(cleanPath, "utf8");
|
||||||
|
const result = transformSourceCode(code, cleanPath, rootDir);
|
||||||
|
records.push(...result.records);
|
||||||
|
});
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCatalogFiles(rootDir) {
|
||||||
|
const records = collectCatalogRecords(rootDir);
|
||||||
|
const catalog = mergeMessageRecords(records);
|
||||||
|
const generatedDir = path.join(rootDir, "src/i18n/generated");
|
||||||
|
const localesDir = path.join(rootDir, "src/i18n/locales");
|
||||||
|
|
||||||
|
writeJSONFile(path.join(generatedDir, "catalog.json"), catalog);
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const localePath = path.join(localesDir, `${locale}.json`);
|
||||||
|
const previousMessages = readJSONFile(localePath, {});
|
||||||
|
const nextMessages = mergeLocaleMessages(previousMessages, catalog.entries, locale);
|
||||||
|
writeJSONFile(localePath, nextMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staticI18nPlugin() {
|
||||||
|
let rootDir = process.cwd();
|
||||||
|
const shouldScan = process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "cursor-static-i18n",
|
||||||
|
enforce: "pre",
|
||||||
|
configResolved(config) {
|
||||||
|
rootDir = config.root;
|
||||||
|
},
|
||||||
|
buildStart() {
|
||||||
|
if (!shouldScan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCatalogFiles(rootDir);
|
||||||
|
},
|
||||||
|
transform(code, id) {
|
||||||
|
if (!isSourceFile(id) || isExcludedFile(rootDir, id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = stripQuery(id);
|
||||||
|
const result = transformSourceCode(code, filePath, rootDir);
|
||||||
|
if (!result.changed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: result.code,
|
||||||
|
map: result.map,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,281 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4f5f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--btn: #111827;
|
||||||
|
--btn-text: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-header {
|
||||||
|
height: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #f8f8f8;
|
||||||
|
color: #6b7280;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.os-windows .window-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-spacer {
|
||||||
|
display: none;
|
||||||
|
width: 48px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.os-windows .header-spacer {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-actions {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.os-windows .window-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn:hover img {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn.close-btn:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-btn.close-btn:hover img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-top,
|
||||||
|
.panel-bottom {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 74px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windows-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.os-windows .windows-only {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 56px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--btn);
|
||||||
|
color: var(--btn-text);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.enabled {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.waiting-init {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-btn:hover:not(:disabled) {
|
||||||
|
border-color: #9ca3af;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-btn {
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-btn:disabled,
|
||||||
|
.init-btn:disabled,
|
||||||
|
.toggle-btn:disabled,
|
||||||
|
.window-btn:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-inline {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status[data-state="idle"] {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status[data-state="checking"] {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status[data-state="ready"] {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status[data-state="pending"] {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.init-status[data-state="error"] {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const vitePackagePath = require.resolve("vite/package.json");
|
||||||
|
const viteBin = path.join(path.dirname(vitePackagePath), "bin", "vite.js");
|
||||||
|
const extraArgs = process.argv.slice(2);
|
||||||
|
const shouldScan = extraArgs.includes("--scan");
|
||||||
|
const forwardedArgs = extraArgs.filter((arg) => arg !== "--scan");
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, [viteBin, "build", ...forwardedArgs], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
STATIC_I18N_SCAN: shouldScan ? "true" : process.env.STATIC_I18N_SCAN || "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<MainLayout />
|
||||||
|
<MessageProvider />
|
||||||
|
<AdModelProvider v-if="isMainWindow" />
|
||||||
|
<Modal
|
||||||
|
|
||||||
|
:visible="modalState.visible"
|
||||||
|
:title="modalState.title"
|
||||||
|
:content="modalState.content"
|
||||||
|
:confirm-text="modalState.confirmText"
|
||||||
|
:cancel-text="modalState.cancelText"
|
||||||
|
:show-cancel="modalState.showCancel"
|
||||||
|
:confirm-disabled="modalState.confirmDisabled"
|
||||||
|
@confirm="resolveModal(true)"
|
||||||
|
@cancel="resolveModal(false)"
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
v-if="isMainWindow"
|
||||||
|
:visible="appState.updatePromptVisible"
|
||||||
|
:title="updateViewState.promptTitle"
|
||||||
|
:content="updateViewState.promptContent"
|
||||||
|
:confirm-text="updateViewState.promptConfirmText"
|
||||||
|
:cancel-text="updateViewState.promptCancelText"
|
||||||
|
:show-cancel="updateViewState.promptShowCancel"
|
||||||
|
:confirm-disabled="appState.updatePromptBusy"
|
||||||
|
@confirm="confirmUpdatePrompt"
|
||||||
|
@cancel="dismissUpdatePrompt"
|
||||||
|
/>
|
||||||
|
<InputModal
|
||||||
|
:visible="inputModalState.visible"
|
||||||
|
:title="inputModalState.title"
|
||||||
|
:content="inputModalState.content"
|
||||||
|
:placeholder="inputModalState.placeholder"
|
||||||
|
:model-value="inputModalState.value"
|
||||||
|
@update:model-value="inputModalState.value = $event"
|
||||||
|
@confirm="resolveInputModal(true)"
|
||||||
|
@cancel="resolveInputModal(false)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import MainLayout from "@/layouts/MainLayout.vue";
|
||||||
|
import AdModelProvider from "@/components/AdModelProvider.vue";
|
||||||
|
import Modal from "@/components/ui/Modal.vue";
|
||||||
|
import MessageProvider from "@/components/ui/MessageProvider.vue";
|
||||||
|
import { modalState, resolveModal } from "@/composables/useModal";
|
||||||
|
|
||||||
|
import InputModal from "@/components/ui/InputModal.vue";
|
||||||
|
import { inputModalState, resolveInputModal } from "@/composables/useInputModal";
|
||||||
|
import { appState, confirmUpdatePrompt, dismissUpdatePrompt, updateViewState } from "@/state/appState";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isMainWindow = computed(() => route.path === "/");
|
||||||
|
</script>
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { getAdRuntime, openAdExternalURL } from "@/services/clientApi";
|
||||||
|
|
||||||
|
const OPEN_AD_EVENT = "cursor:open-ad";
|
||||||
|
const BRIDGE_SOURCE = "cursor-ad";
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const runtimeState = ref(null);
|
||||||
|
const iframeSrc = ref("");
|
||||||
|
const viewport = ref({
|
||||||
|
width: typeof window === "undefined" ? 1024 : window.innerWidth,
|
||||||
|
height: typeof window === "undefined" ? 768 : window.innerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showingHashes = new Set();
|
||||||
|
let refreshPending = false;
|
||||||
|
let hideTimer = 0;
|
||||||
|
|
||||||
|
const frameStyle = computed(() => {
|
||||||
|
const win = runtimeState.value?.window ?? {};
|
||||||
|
const maxWidth = Math.max(220, viewport.value.width - 32);
|
||||||
|
const maxHeight = Math.max(160, viewport.value.height - 32);
|
||||||
|
const width = Math.min(clampNumber(win.width, 280, 1200, 640), maxWidth);
|
||||||
|
const height = Math.min(clampNumber(win.height, 180, 900, 420), maxHeight);
|
||||||
|
return {
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
maxWidth: "calc(100vw - 32px)",
|
||||||
|
maxHeight: "calc(100vh - 32px)",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function asString(value) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBoolean(value) {
|
||||||
|
return value === true || value === "true" || value === 1 || value === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value, min, max, fallback) {
|
||||||
|
const parsed = asNumber(value, fallback);
|
||||||
|
return Math.min(max, Math.max(min, parsed || fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntime(source, preferredSlotId = "") {
|
||||||
|
const raw = source && typeof source === "object" ? source : {};
|
||||||
|
const slots = Array.isArray(raw.slots) ? raw.slots : [];
|
||||||
|
const selectedSlot =
|
||||||
|
slots.find((slot) => asString(slot?.id) === asString(preferredSlotId)) ||
|
||||||
|
slots[0] ||
|
||||||
|
raw;
|
||||||
|
const slot = selectedSlot && typeof selectedSlot === "object" ? selectedSlot : {};
|
||||||
|
const win = raw.window && typeof raw.window === "object" ? raw.window : {};
|
||||||
|
const slotWin = slot.window && typeof slot.window === "object" ? slot.window : win;
|
||||||
|
return {
|
||||||
|
id: asString(slot.id) || asString(preferredSlotId) || "1",
|
||||||
|
available: asBoolean(slot.available),
|
||||||
|
enabled: asBoolean(slot.enabled),
|
||||||
|
packageHash: asString(slot.packageHash),
|
||||||
|
assetBaseURL: asString(slot.assetBaseURL).replace(/\/+$/, ""),
|
||||||
|
indexURL: asString(slot.indexURL),
|
||||||
|
window: {
|
||||||
|
width: Math.round(asNumber(slotWin.width, 640)),
|
||||||
|
height: Math.round(asNumber(slotWin.height, 420)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedAdOrigin() {
|
||||||
|
const baseURL = runtimeState.value?.assetBaseURL;
|
||||||
|
if (!baseURL) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(baseURL).origin;
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOpen(runtime) {
|
||||||
|
if (!runtime?.available || !runtime.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(runtime.packageHash && runtime.assetBaseURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCurrentAd(slotId = "") {
|
||||||
|
if (visible.value || refreshPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshPending = true;
|
||||||
|
try {
|
||||||
|
const nextRuntime = normalizeRuntime(await getAdRuntime(), slotId);
|
||||||
|
runtimeState.value = nextRuntime;
|
||||||
|
if (canOpen(nextRuntime)) {
|
||||||
|
await showAd(nextRuntime);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// 广告入口失败不影响主界面。
|
||||||
|
} finally {
|
||||||
|
refreshPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAd(runtime) {
|
||||||
|
const hash = runtime.packageHash;
|
||||||
|
if (showingHashes.has(hash)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showingHashes.add(hash);
|
||||||
|
try {
|
||||||
|
const indexURL = runtime.indexURL || `${runtime.assetBaseURL}/index.html`;
|
||||||
|
const separator = indexURL.includes("?") ? "&" : "?";
|
||||||
|
iframeSrc.value = `${indexURL}${separator}hash=${encodeURIComponent(hash)}&ts=${Date.now()}`;
|
||||||
|
visible.value = true;
|
||||||
|
} finally {
|
||||||
|
showingHashes.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAd() {
|
||||||
|
visible.value = false;
|
||||||
|
if (hideTimer) {
|
||||||
|
window.clearTimeout(hideTimer);
|
||||||
|
}
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
iframeSrc.value = "";
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(event) {
|
||||||
|
const origin = expectedAdOrigin();
|
||||||
|
if (origin && event.origin !== origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = event.data && typeof event.data === "object" ? event.data : {};
|
||||||
|
if (data.source !== BRIDGE_SOURCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "close") {
|
||||||
|
closeAd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "openExternal") {
|
||||||
|
const targetURL = asString(data.url);
|
||||||
|
if (targetURL) {
|
||||||
|
void openAdExternalURL(targetURL).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenRequested(event) {
|
||||||
|
void openCurrentAd(asString(event?.detail?.slotId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateViewport() {
|
||||||
|
viewport.value = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
window.addEventListener(OPEN_AD_EVENT, handleOpenRequested);
|
||||||
|
window.addEventListener("resize", updateViewport);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (hideTimer) {
|
||||||
|
window.clearTimeout(hideTimer);
|
||||||
|
}
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
window.removeEventListener(OPEN_AD_EVENT, handleOpenRequested);
|
||||||
|
window.removeEventListener("resize", updateViewport);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-mask">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
>
|
||||||
|
<Transition name="ad-frame">
|
||||||
|
<iframe
|
||||||
|
v-show="visible && iframeSrc"
|
||||||
|
:src="iframeSrc"
|
||||||
|
:style="frameStyle"
|
||||||
|
class="block overflow-hidden rounded-none border-none bg-transparent shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||||
|
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||||
|
title="Advertisement"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-mask-enter-active,
|
||||||
|
.modal-mask-leave-active {
|
||||||
|
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask-enter-from,
|
||||||
|
.modal-mask-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-frame-enter-active,
|
||||||
|
.ad-frame-leave-active {
|
||||||
|
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-frame-enter-from,
|
||||||
|
.ad-frame-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/ui/Button.vue";
|
||||||
|
import Card from "@/components/ui/Card.vue";
|
||||||
|
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||||
|
import { showModal } from "@/composables/useModal";
|
||||||
|
import {
|
||||||
|
disconnectCursorAccount,
|
||||||
|
getCursorAccountStatus,
|
||||||
|
startCursorAccountLogin,
|
||||||
|
} from "@/services/clientApi";
|
||||||
|
import { toUserError } from "@/state/appState";
|
||||||
|
import { Browser } from "@wailsio/runtime";
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||||
|
|
||||||
|
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
|
||||||
|
|
||||||
|
const cursorAccountStatus = ref({
|
||||||
|
state: "signed_out",
|
||||||
|
authId: "",
|
||||||
|
email: "",
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
const cursorAccountBusy = ref(false);
|
||||||
|
let cursorAccountTimer = null;
|
||||||
|
|
||||||
|
const cursorAccountSignedIn = computed(
|
||||||
|
() => cursorAccountStatus.value.state === "signed_in",
|
||||||
|
);
|
||||||
|
const cursorAccountWaiting = computed(
|
||||||
|
() => cursorAccountStatus.value.state === "waiting",
|
||||||
|
);
|
||||||
|
const cursorAccountStateText = computed(() => {
|
||||||
|
if (cursorAccountSignedIn.value) return "已经登录";
|
||||||
|
if (cursorAccountWaiting.value) return "等待浏览器登录";
|
||||||
|
return "未连接";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showActionError(title, error) {
|
||||||
|
await showModal({
|
||||||
|
title,
|
||||||
|
content: String(error || "服务错误").trim() || "服务错误",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenContributor() {
|
||||||
|
try {
|
||||||
|
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
|
||||||
|
} catch (error) {
|
||||||
|
await showActionError("打开贡献者主页失败", toUserError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCursorAccountStatus() {
|
||||||
|
cursorAccountStatus.value = await getCursorAccountStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCursorAccountLogin() {
|
||||||
|
cursorAccountBusy.value = true;
|
||||||
|
try {
|
||||||
|
cursorAccountStatus.value = await startCursorAccountLogin();
|
||||||
|
} catch (error) {
|
||||||
|
await showActionError("登录失败", toUserError(error));
|
||||||
|
await refreshCursorAccountStatus().catch(() => {});
|
||||||
|
} finally {
|
||||||
|
cursorAccountBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCursorAccountDisconnect() {
|
||||||
|
const confirmed = await showModal({
|
||||||
|
title: "退出登录",
|
||||||
|
content: "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
||||||
|
confirmText: "退出登录",
|
||||||
|
cancelText: "取消",
|
||||||
|
showCancel: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
cursorAccountBusy.value = true;
|
||||||
|
try {
|
||||||
|
cursorAccountStatus.value = await disconnectCursorAccount();
|
||||||
|
} catch (error) {
|
||||||
|
await showActionError("退出登录失败", toUserError(error));
|
||||||
|
} finally {
|
||||||
|
cursorAccountBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCursorAccountStatus().catch(() => {});
|
||||||
|
cursorAccountTimer = window.setInterval(() => {
|
||||||
|
if (cursorAccountWaiting.value) {
|
||||||
|
void refreshCursorAccountStatus().catch(() => {});
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (cursorAccountTimer) {
|
||||||
|
window.clearInterval(cursorAccountTimer);
|
||||||
|
cursorAccountTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-base font-medium text-white">Cursor 控制面账号</h2>
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-[#3a3a3a] bg-[#202020] px-2 py-0.5 text-xs text-[#b8b8b8]"
|
||||||
|
>
|
||||||
|
{{ cursorAccountStateText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1 text-xs text-[#737373]">
|
||||||
|
<span>@aike0210</span>
|
||||||
|
<Tooltip>
|
||||||
|
<div class="flex min-w-[220px] flex-col gap-2">
|
||||||
|
<div>感谢 @aike0210 对 Cursor 控制面账号功能的贡献。</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 text-left text-[#8ab4f8] transition-colors duration-150 hover:text-[#b6d0fb]"
|
||||||
|
@click="handleOpenContributor"
|
||||||
|
>
|
||||||
|
<span class="icon-[mdi--github] text-[14px]"></span>
|
||||||
|
<span>github.com/aike0210</span>
|
||||||
|
<span class="icon-[mdi--open-in-new] text-[12px]"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div
|
||||||
|
v-if="cursorAccountSignedIn && (cursorAccountStatus.email || cursorAccountStatus.authId)"
|
||||||
|
class="truncate text-sm text-[#d0d0d0]"
|
||||||
|
>
|
||||||
|
{{ cursorAccountStatus.email || cursorAccountStatus.authId }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-[#a3a3a3]">
|
||||||
|
独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号
|
||||||
|
</div>
|
||||||
|
<div v-if="cursorAccountWaiting" class="mt-1 text-sm text-[#d6a84b]">
|
||||||
|
请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cursorAccountStatus.error"
|
||||||
|
class="mt-1 break-all text-sm text-[#e06c75]"
|
||||||
|
>
|
||||||
|
{{ cursorAccountStatus.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="cursorAccountSignedIn"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="cursorAccountBusy"
|
||||||
|
@click="handleCursorAccountDisconnect"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
class="shrink-0"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="cursorAccountBusy || cursorAccountWaiting"
|
||||||
|
@click="handleCursorAccountLogin"
|
||||||
|
>
|
||||||
|
{{ cursorAccountWaiting ? "等待登录..." : "登录 Cursor" }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script setup>
|
||||||
|
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
|
||||||
|
import Switch from "@/components/ui/Switch.vue";
|
||||||
|
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||||
|
import { appState, saveIncludeCacheWriteInHitRate } from "@/state/appState";
|
||||||
|
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["refresh", "open-ad"]);
|
||||||
|
|
||||||
|
const TOKEN_PRICE_PER_MILLION = {
|
||||||
|
input: 5,
|
||||||
|
output: 25,
|
||||||
|
cacheRead: 0.5,
|
||||||
|
cacheWrite: 6.25,
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
metrics: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
homeAd: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
homeAds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeMetricsConfigSaving = ref(false);
|
||||||
|
const homeMetricsConfigError = ref("");
|
||||||
|
|
||||||
|
function normalizeNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.round(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(value) {
|
||||||
|
const full = formatInteger(value);
|
||||||
|
const compact = formatCompactInteger(value);
|
||||||
|
return full === compact ? full : `${full} (${compact})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRateLabel(value) {
|
||||||
|
const rate = Number(value);
|
||||||
|
if (!Number.isFinite(rate)) {
|
||||||
|
return "暂无数据";
|
||||||
|
}
|
||||||
|
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRate(numerator, denominator) {
|
||||||
|
const top = normalizeNumber(numerator);
|
||||||
|
const bottom = normalizeNumber(denominator);
|
||||||
|
if (bottom <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return top / bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceTokens(tokens, pricePerMillion) {
|
||||||
|
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUSD(value) {
|
||||||
|
const amount = Number(value);
|
||||||
|
if (!Number.isFinite(amount)) {
|
||||||
|
return "$0.00";
|
||||||
|
}
|
||||||
|
if (amount > 0 && amount < 0.01) {
|
||||||
|
return "<$0.01";
|
||||||
|
}
|
||||||
|
return `$${amount.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
|
||||||
|
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
|
||||||
|
|
||||||
|
const inputTokensTotal = computed(() => {
|
||||||
|
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
|
||||||
|
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultCacheHitRate = computed(() =>
|
||||||
|
calculateRate(cacheReadTokensTotal.value, cacheReadTokensTotal.value + inputTokensTotal.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cacheReuseRate = computed(() =>
|
||||||
|
calculateRate(
|
||||||
|
cacheReadTokensTotal.value,
|
||||||
|
cacheReadTokensTotal.value + cacheWriteTokensTotal.value + inputTokensTotal.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
|
||||||
|
|
||||||
|
const selectedCacheHitRate = computed(() =>
|
||||||
|
includeCacheWriteInHitRate.value ? cacheReuseRate.value : defaultCacheHitRate.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCacheRateModeLabel = computed(() =>
|
||||||
|
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
|
||||||
|
);
|
||||||
|
|
||||||
|
const validTurnsRate = computed(() => {
|
||||||
|
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
|
||||||
|
if (turnsTotal <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionTokensTotal = computed(() => {
|
||||||
|
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
|
||||||
|
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
|
||||||
|
return Math.max(0, requestTokensTotal - promptTokensTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimatedTokenCost = computed(() => {
|
||||||
|
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
|
||||||
|
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
|
||||||
|
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
|
||||||
|
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
total: input + output + cacheRead + cacheWrite,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheTooltipContent = computed(() => {
|
||||||
|
const formula = includeCacheWriteInHitRate.value
|
||||||
|
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
|
||||||
|
: "缓存读取 /(缓存读取 + 非缓存输入)";
|
||||||
|
return [
|
||||||
|
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
|
||||||
|
`公式:${formula}`,
|
||||||
|
`默认 ${formatRateLabel(defaultCacheHitRate.value)} / 计入创建 ${formatRateLabel(cacheReuseRate.value)}`,
|
||||||
|
].join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnsTooltipContent = computed(() =>
|
||||||
|
[
|
||||||
|
"按历史记录里扫描到的回合 summary 汇总。",
|
||||||
|
"",
|
||||||
|
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
|
||||||
|
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
|
||||||
|
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
|
||||||
|
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokensTooltipContent = computed(() =>
|
||||||
|
[
|
||||||
|
"总请求 Token 包含 Prompt 和模型输出。",
|
||||||
|
"",
|
||||||
|
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
|
||||||
|
`Prompt:${formatMetricValue(props.metrics?.promptTokensTotal)}`,
|
||||||
|
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
|
||||||
|
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
|
||||||
|
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
|
||||||
|
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
|
||||||
|
"",
|
||||||
|
"缓存读写已计入 Prompt 侧统计。",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const costTooltipContent = computed(() =>
|
||||||
|
[
|
||||||
|
"按 Claude Opus 4.7 价格估算。",
|
||||||
|
`缓存统计策略:${selectedCacheRateModeLabel.value}(${formatRateLabel(selectedCacheHitRate.value)})`,
|
||||||
|
"",
|
||||||
|
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
|
||||||
|
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
|
||||||
|
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
|
||||||
|
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
|
||||||
|
"",
|
||||||
|
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeHomeAd(item, index) {
|
||||||
|
const source = item && typeof item === "object" ? item : {};
|
||||||
|
const title = typeof source.title === "string" ? source.title.trim() : "";
|
||||||
|
if (!title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : String(index + 1),
|
||||||
|
title,
|
||||||
|
subtitle: typeof source.subtitle === "string" ? source.subtitle.trim() : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleIncludeCacheWriteInHitRate(value) {
|
||||||
|
const nextValue = Boolean(value);
|
||||||
|
homeMetricsConfigSaving.value = true;
|
||||||
|
homeMetricsConfigError.value = "";
|
||||||
|
try {
|
||||||
|
const result = await saveIncludeCacheWriteInHitRate(nextValue);
|
||||||
|
if (!result?.ok) {
|
||||||
|
homeMetricsConfigError.value = result?.error || "保存失败";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
homeMetricsConfigError.value = error?.message || "保存失败";
|
||||||
|
} finally {
|
||||||
|
homeMetricsConfigSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHomeAds = computed(() => {
|
||||||
|
const list = Array.isArray(props.homeAds) && props.homeAds.length > 0 ? props.homeAds : [props.homeAd];
|
||||||
|
return list.map(normalizeHomeAd).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-4 h-[42px]">
|
||||||
|
<div v-if="!hasHomeAd" class="flex flex-col gap-1 w-[200px] shrink-0">
|
||||||
|
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid min-w-0 grid-cols-3 gap-2 shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="ad in normalizedHomeAds"
|
||||||
|
:key="ad.id"
|
||||||
|
style="font-family: var(--font-num)"
|
||||||
|
class="center-row h-[42px] min-w-0 cursor-pointer gap-[8px] rounded-[6px] border border-[#343434] bg-[#242424] px-[8px] pr-[10px] text-left transition-colors duration-150 hover:border-[#4a4a4a] hover:bg-[#2a2a2a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:title="ad.subtitle ? `${ad.title}\n${ad.subtitle}` : ad.title"
|
||||||
|
@click="emit('open-ad', ad.id)"
|
||||||
|
@keydown.enter.prevent="emit('open-ad', ad.id)"
|
||||||
|
@keydown.space.prevent="emit('open-ad', ad.id)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="center-row h-[20px] w-[20px] shrink-0 justify-center text-[20px] text-amber-400"
|
||||||
|
>
|
||||||
|
<span class="icon-[cil--badge]"></span>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-[13px] font-medium leading-[16px] text-white">
|
||||||
|
{{ ad.title }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="ad.subtitle"
|
||||||
|
class="mt-[2px] center-row min-w-0 gap-[2px] text-[11px] leading-[12px] text-[#8A8A8A]"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ ad.subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="loading"
|
||||||
|
:title="loading ? '刷新中' : '刷新统计'"
|
||||||
|
@click="emit('refresh')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[mdi--refresh] text-[14px]"
|
||||||
|
:class="{ '!animate-spin': loading }"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-[-4px] grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#343434] bg-[#242424] h-[130px]"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 px-4 py-4 flex flex-col justify-between">
|
||||||
|
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||||
|
<span>缓存命中率</span>
|
||||||
|
<Tooltip>
|
||||||
|
<div class="w-[280px] space-y-3">
|
||||||
|
<div class="border-b border-[#343434] pb-3">
|
||||||
|
<Switch
|
||||||
|
compact
|
||||||
|
label="计入缓存创建"
|
||||||
|
description="开启后把缓存创建纳入分母"
|
||||||
|
enabled-text="当前按复用率口径显示"
|
||||||
|
disabled-text="当前按默认命中率口径显示"
|
||||||
|
:enabled="includeCacheWriteInHitRate"
|
||||||
|
:busy="homeMetricsConfigSaving"
|
||||||
|
:disabled="homeMetricsConfigSaving"
|
||||||
|
@change="toggleIncludeCacheWriteInHitRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
|
||||||
|
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#f87171]">
|
||||||
|
{{ homeMetricsConfigError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<CacheHitRateChart :rate="selectedCacheHitRate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||||
|
<span>对话轮次</span>
|
||||||
|
<Tooltip :content="turnsTooltipContent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="text-[30px] leading-none text-white"
|
||||||
|
style="font-family: var(--font-num)"
|
||||||
|
:title="formatInteger(metrics.turnsTotal)"
|
||||||
|
>
|
||||||
|
{{ formatCompactInteger(metrics.turnsTotal) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||||
|
有效
|
||||||
|
<span :title="formatInteger(metrics.validTurnsTotal)">
|
||||||
|
{{ formatCompactInteger(metrics.validTurnsTotal) }}
|
||||||
|
</span>
|
||||||
|
/ 异常
|
||||||
|
<span :title="formatInteger(metrics.invalidTurnsTotal)">
|
||||||
|
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||||
|
<span>Token 消耗</span>
|
||||||
|
<Tooltip :content="tokensTooltipContent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="truncate text-[30px] leading-none text-white"
|
||||||
|
style="font-family: var(--font-num)"
|
||||||
|
:title="formatInteger(metrics.requestTokensTotal)"
|
||||||
|
>
|
||||||
|
{{ formatCompactInteger(metrics.requestTokensTotal) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||||
|
Prompt
|
||||||
|
<span :title="formatInteger(metrics.promptTokensTotal)">
|
||||||
|
{{ formatCompactInteger(metrics.promptTokensTotal) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
|
||||||
|
<span>价值估算</span>
|
||||||
|
<Tooltip :content="costTooltipContent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="truncate text-[30px] leading-none text-white"
|
||||||
|
style="font-family: var(--font-num)"
|
||||||
|
:title="formatUSD(estimatedTokenCost.total)"
|
||||||
|
>
|
||||||
|
{{ formatUSD(estimatedTokenCost.total) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
|
||||||
|
缓存读写
|
||||||
|
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
|
||||||
|
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import Select from "@/components/ui/Select.vue";
|
||||||
|
import { useLocale } from "@/i18n/runtime";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
border: { type: Boolean, default: true },
|
||||||
|
ariaLabel: { type: String, default: "界面语言" },
|
||||||
|
buttonClass: { type: String, default: "" },
|
||||||
|
menuClass: { type: String, default: "" },
|
||||||
|
wrapperClass: { type: String, default: "w-[180px] max-w-full" },
|
||||||
|
placeholder: { type: String, default: "选择语言" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { locale, localeOptions, setLocale } = useLocale();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="wrapperClass">
|
||||||
|
<Select
|
||||||
|
:model-value="locale"
|
||||||
|
:options="localeOptions"
|
||||||
|
:border="border"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:button-class="buttonClass"
|
||||||
|
:menu-class="menuClass"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@update:model-value="setLocale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/ui/Button.vue";
|
||||||
|
import Select from "@/components/ui/Select.vue";
|
||||||
|
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||||
|
import {
|
||||||
|
ANTHROPIC_THINKING_EFFORT_DEFAULT,
|
||||||
|
createEmptyModelAdapter,
|
||||||
|
normalizeModelAdapter,
|
||||||
|
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
|
||||||
|
OPENAI_ENDPOINT_CUSTOM,
|
||||||
|
OPENAI_ENDPOINT_RESPONSES,
|
||||||
|
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||||
|
} from "@/state/appState";
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
|
||||||
|
const modelTypeOptions = [
|
||||||
|
{ label: "openai", value: "openai", icon: "icon-[bxl--openai]" },
|
||||||
|
{ label: "anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reasoningEffortOptions = [
|
||||||
|
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||||
|
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||||
|
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||||
|
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||||
|
{ label: "最高", value: "max", icon: "icon-[mdi--brain]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const anthropicThinkingEffortOptions = [
|
||||||
|
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||||
|
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||||
|
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||||
|
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||||
|
{ label: "最大", value: "max", icon: "icon-[mdi--brain]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const openAIEndpointOptions = [
|
||||||
|
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
|
||||||
|
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
|
||||||
|
{ label: "自定义路径", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldTips = {
|
||||||
|
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: "模型配置" },
|
||||||
|
adapter: {
|
||||||
|
type: Object,
|
||||||
|
default: () => createEmptyModelAdapter(),
|
||||||
|
},
|
||||||
|
errorMessage: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["cancel", "save"]);
|
||||||
|
|
||||||
|
const draft = reactive(createEmptyModelAdapter());
|
||||||
|
|
||||||
|
function createOptionalPositiveIntegerModel(key) {
|
||||||
|
return computed({
|
||||||
|
get() {
|
||||||
|
return draft[key] > 0 ? String(draft[key]) : "";
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
draft[key] = /^\d+$/.test(text) && Number(text) > 0 ? Number(text) : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCompletionTokensInput = createOptionalPositiveIntegerModel("maxCompletionTokens");
|
||||||
|
const anthropicMaxTokensInput = createOptionalPositiveIntegerModel("anthropicMaxTokens");
|
||||||
|
const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWindowTokens");
|
||||||
|
|
||||||
|
function ensureOpenAIExtraParamsJSON() {
|
||||||
|
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
|
||||||
|
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAnthropicThinkingEffort() {
|
||||||
|
if (!String(draft.anthropicThinkingEffort || "").trim()) {
|
||||||
|
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDraft() {
|
||||||
|
Object.assign(draft, normalizeModelAdapter(props.adapter));
|
||||||
|
if (!draft.type) {
|
||||||
|
draft.type = "openai";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
syncDraft();
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.adapter, () => {
|
||||||
|
if (props.visible) {
|
||||||
|
syncDraft();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => draft.type, (type) => {
|
||||||
|
if (type === "openai" && !draft.openAIEndpoint) {
|
||||||
|
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
||||||
|
} else if (type === "anthropic") {
|
||||||
|
ensureAnthropicThinkingEffort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => draft.openAIExtraParamsEnabled, (enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
ensureOpenAIExtraParamsJSON();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
emit("save", normalizeModelAdapter(draft));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-mask">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
@click.self="handleCancel"
|
||||||
|
>
|
||||||
|
<Transition name="modal-content">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="relative z-10 w-full max-w-[560px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||||
|
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||||
|
<h3 class="mb-4 text-base font-medium text-white">{{ title }}</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">显示名称</span>
|
||||||
|
<input
|
||||||
|
v-model="draft.displayName"
|
||||||
|
type="text"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">ModelID</span>
|
||||||
|
<input
|
||||||
|
v-model="draft.modelID"
|
||||||
|
type="text"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">类型</span>
|
||||||
|
<Select
|
||||||
|
v-model="draft.type"
|
||||||
|
:options="modelTypeOptions"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">API Key</span>
|
||||||
|
<input
|
||||||
|
v-model="draft.apiKey"
|
||||||
|
type="text"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-3 flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">baseURL</span>
|
||||||
|
<input
|
||||||
|
v-model="draft.baseURL"
|
||||||
|
type="text"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="mt-3 flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">context_window_tokens</span>
|
||||||
|
<input
|
||||||
|
v-model="contextWindowTokensInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="留空时默认 200000"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="draft.type === 'openai'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">reasoning_effort</span>
|
||||||
|
<Select
|
||||||
|
v-model="draft.reasoningEffort"
|
||||||
|
:options="reasoningEffortOptions"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">max token</span>
|
||||||
|
<input
|
||||||
|
v-model="maxCompletionTokensInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="留空时默认 65536"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">endpoint</span>
|
||||||
|
<Select
|
||||||
|
v-model="draft.openAIEndpoint"
|
||||||
|
:options="openAIEndpointOptions"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="draft.type === 'openai'" class="mt-3 rounded-[8px] border border-[#343434] bg-[#252525] p-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="flex items-center gap-1.5 text-sm text-[#d4d4d4]">
|
||||||
|
<Tooltip :content="fieldTips.openAIExtraParams" />
|
||||||
|
<span>额外参数 JSON</span>
|
||||||
|
</span>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-[#d4d4d4]">
|
||||||
|
<input
|
||||||
|
v-model="draft.openAIExtraParamsEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 accent-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-if="draft.openAIExtraParamsEnabled"
|
||||||
|
v-model="draft.openAIExtraParamsJSON"
|
||||||
|
rows="5"
|
||||||
|
spellcheck="false"
|
||||||
|
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="draft.type === 'anthropic'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">max_tokens</span>
|
||||||
|
<input
|
||||||
|
v-model="anthropicMaxTokensInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="留空时默认 65536"
|
||||||
|
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">thinking effort</span>
|
||||||
|
<Select
|
||||||
|
v-model="draft.anthropicThinkingEffort"
|
||||||
|
:options="anthropicThinkingEffortOptions"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="mt-3 flex flex-col gap-1">
|
||||||
|
<span class="text-sm text-[#d4d4d4]">tooltipData</span>
|
||||||
|
<textarea
|
||||||
|
v-model="draft.tooltipData"
|
||||||
|
rows="5"
|
||||||
|
class="min-h-[120px] resize-none rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 py-2 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<Button variant="default" @click="handleCancel">取消</Button>
|
||||||
|
<Button variant="primary" @click="handleSave">保存</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-mask-enter-active,
|
||||||
|
.modal-mask-leave-active {
|
||||||
|
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.modal-mask-enter-from,
|
||||||
|
.modal-mask-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-enter-active,
|
||||||
|
.modal-content-leave-active {
|
||||||
|
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.modal-content-enter-from,
|
||||||
|
.modal-content-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||||
|
import { formatDuration } from "@/state/appState";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
result: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
stale: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showMetrics: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "模型测试",
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
type: String,
|
||||||
|
default: "尚未测试",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedStatus = computed(() => {
|
||||||
|
const status = String(props.result?.status || "").trim().toLowerCase();
|
||||||
|
return ["running", "success", "error"].includes(status) ? status : "idle";
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryText = computed(() => {
|
||||||
|
const text = String(props.result?.summaryText || "").trim();
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "running") {
|
||||||
|
return "测试中...";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "error") {
|
||||||
|
return "测试失败";
|
||||||
|
}
|
||||||
|
return props.emptyText;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawResponseText = computed(() => {
|
||||||
|
const raw = String(props.result?.rawResponse || "").trim();
|
||||||
|
if (raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "error") {
|
||||||
|
return String(props.result?.error || "").trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const panelClass = computed(() => {
|
||||||
|
if (props.stale) {
|
||||||
|
return "border-[#6b5b1e] bg-[#2c2612]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "running") {
|
||||||
|
return "border-[#164e63] bg-[#0b2530]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "error") {
|
||||||
|
return "border-[#4b1d1d] bg-[#2a1313]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
|
||||||
|
return "border-[#5a4314] bg-[#2f2612]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "success") {
|
||||||
|
return "border-[#14532d] bg-[#102418]";
|
||||||
|
}
|
||||||
|
return "border-[#343434] bg-[#232323]";
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryClass = computed(() => {
|
||||||
|
if (props.stale) {
|
||||||
|
return "text-[#f6d77a]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "running") {
|
||||||
|
return "text-[#67e8f9]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "error") {
|
||||||
|
return "text-[#fca5a5]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
|
||||||
|
return "text-[#fcd34d]";
|
||||||
|
}
|
||||||
|
if (normalizedStatus.value === "success") {
|
||||||
|
return "text-[#86efac]";
|
||||||
|
}
|
||||||
|
return "text-[#a3a3a3]";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
|
||||||
|
<span>原始返回</span>
|
||||||
|
<Tooltip :content="rawResponseText" copyable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm leading-relaxed" :class="summaryClass">
|
||||||
|
{{ summaryText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="stale"
|
||||||
|
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
|
||||||
|
>
|
||||||
|
需重测
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
|
||||||
|
配置已变更,请重新测试
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showMetrics && normalizedStatus === 'success'"
|
||||||
|
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
|
||||||
|
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
|
||||||
|
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
|
||||||
|
class="mt-2 text-xs text-[#8f8f8f]"
|
||||||
|
>
|
||||||
|
输出 Token 为估算值
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ArcElement,
|
||||||
|
Chart as ChartJS,
|
||||||
|
Tooltip,
|
||||||
|
} from "chart.js";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { Doughnut } from "vue-chartjs";
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip);
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rate: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const percentage = computed(() => {
|
||||||
|
const rate = Number(props.rate);
|
||||||
|
if (!Number.isFinite(rate)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, rate * 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const rate = Number(props.rate);
|
||||||
|
if (!Number.isFinite(rate)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
return `${percentage.value.toFixed(2)}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSegmentBorderRadius(dataIndex) {
|
||||||
|
const radius = 5;
|
||||||
|
|
||||||
|
if (percentage.value <= 0) {
|
||||||
|
return dataIndex === 1
|
||||||
|
? {
|
||||||
|
outerStart: radius,
|
||||||
|
outerEnd: radius,
|
||||||
|
innerStart: radius,
|
||||||
|
innerEnd: radius,
|
||||||
|
}
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percentage.value >= 100) {
|
||||||
|
return dataIndex === 0
|
||||||
|
? {
|
||||||
|
outerStart: radius,
|
||||||
|
outerEnd: radius,
|
||||||
|
innerStart: radius,
|
||||||
|
innerEnd: radius,
|
||||||
|
}
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataIndex === 0
|
||||||
|
? {
|
||||||
|
outerStart: radius,
|
||||||
|
outerEnd: 0,
|
||||||
|
innerStart: radius,
|
||||||
|
innerEnd: 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
outerStart: 0,
|
||||||
|
outerEnd: radius,
|
||||||
|
innerStart: 0,
|
||||||
|
innerEnd: radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: ["命中", "未命中"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [percentage.value, Math.max(0, 100 - percentage.value)],
|
||||||
|
backgroundColor: ["#4ade80", "#373737"],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverBorderWidth: 0,
|
||||||
|
selfJoin: false,
|
||||||
|
borderRadius: ({ dataIndex }) => getSegmentBorderRadius(dataIndex),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: "82%",
|
||||||
|
rotation: -90,
|
||||||
|
circumference: 180,
|
||||||
|
animation: {
|
||||||
|
duration: 450,
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="relative h-[82px] w-[132px] shrink-0"
|
||||||
|
role="img"
|
||||||
|
:aria-label="`缓存命中率 ${label}`"
|
||||||
|
>
|
||||||
|
<Doughnut class="h-full w-full" :data="chartData" :options="chartOptions" />
|
||||||
|
<div class="pointer-events-none absolute inset-x-0 bottom-[10px] flex justify-center">
|
||||||
|
<div
|
||||||
|
class="text-[20px] leading-none text-white"
|
||||||
|
style="font-family: var(--font-num)"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
validator: (v) => ["default", "primary", "text"].includes(v),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="variant === 'text'"
|
||||||
|
type="button"
|
||||||
|
class="!whitespace-nowrap shrink-0 cursor-pointer text-sm text-[#a3a3a3] transition-colors duration-150 active:text-[#10AD5D] hover:text-[#10ad5cd9]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="!whitespace-nowrap relative cursor-pointer overflow-hidden center-row min-h-[24px] gap-[2px] rounded-[6px] text-sm transition-transform duration-150 active:scale-105"
|
||||||
|
:class="{
|
||||||
|
'bg-[linear-gradient(to_bottom,#656565_0%,#3A3A3A_10px,#3A3A3A_100%)]': variant === 'default',
|
||||||
|
'bg-gradient-to-b from-[#1D8010] to-[#25B433]': variant === 'primary',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="relative center-row z-10 w-full justify-center rounded-[5px] !px-[7px] py-[3px] text-white transition-colors"
|
||||||
|
:class="{
|
||||||
|
'bg-gradient-to-b from-[#2a2a2a] to-[#1f1f1f] ': variant === 'default',
|
||||||
|
'font-medium bg-gradient-to-b from-[#10AD5D] to-[#0F8A4C] ': variant === 'primary',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup></script>
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rounded-[8px] p-[1px]"
|
||||||
|
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||||
|
>
|
||||||
|
<div class="rounded-[7px] bg-[#292929] p-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, useAttrs, watch } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: "" },
|
||||||
|
type: { type: String, default: "text" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
allowVisibilityToggle: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const isPasswordVisible = ref(false);
|
||||||
|
|
||||||
|
const canToggleVisibility = computed(() => props.type === "password" && props.allowVisibilityToggle);
|
||||||
|
const inputType = computed(() => {
|
||||||
|
if (!canToggleVisibility.value) {
|
||||||
|
return props.type;
|
||||||
|
}
|
||||||
|
return isPasswordVisible.value ? "text" : "password";
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.type, props.allowVisibilityToggle],
|
||||||
|
([type, allowVisibilityToggle]) => {
|
||||||
|
if (type !== "password" || !allowVisibilityToggle) {
|
||||||
|
isPasswordVisible.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleInput(event) {
|
||||||
|
emit("update:modelValue", event?.target?.value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisibility() {
|
||||||
|
if (!canToggleVisibility.value || props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative w-full">
|
||||||
|
<input
|
||||||
|
v-bind="attrs"
|
||||||
|
:value="modelValue"
|
||||||
|
:type="inputType"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:class="canToggleVisibility ? 'pr-10' : ''"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canToggleVisibility"
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 center-row px-3 text-[#8f8f8f] transition-colors hover:text-[#d4d4d4] focus:text-[#d4d4d4] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:aria-label="isPasswordVisible ? '隐藏访问密钥' : '显示访问密钥'"
|
||||||
|
:aria-pressed="isPasswordVisible"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="toggleVisibility"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
isPasswordVisible ? 'icon-[mdi--eye-off-outline]' : 'icon-[mdi--eye-outline]',
|
||||||
|
'text-[18px]',
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/ui/Button.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: "提示" },
|
||||||
|
content: { type: String, default: "" },
|
||||||
|
placeholder: { type: String, default: "" },
|
||||||
|
modelValue: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:visible", "update:modelValue", "confirm", "cancel"]);
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit("confirm");
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("cancel");
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMaskClick() {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event) {
|
||||||
|
emit("update:modelValue", event?.target?.value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnter(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleConfirm();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-mask">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
@click.self="onMaskClick"
|
||||||
|
>
|
||||||
|
<Transition name="modal-content">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="relative z-10 w-full max-w-[380px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||||
|
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||||
|
<h3 class="mb-3 text-base font-medium text-white">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-3 text-sm leading-relaxed text-[#a3a3a3]">
|
||||||
|
{{ content }}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
type="text"
|
||||||
|
class="mb-5 h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown.enter="onEnter"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="default" @click="handleCancel">取消</Button>
|
||||||
|
<Button variant="primary" @click="handleConfirm">确定</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-mask-enter-active,
|
||||||
|
.modal-mask-leave-active {
|
||||||
|
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.modal-mask-enter-from,
|
||||||
|
.modal-mask-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-enter-active,
|
||||||
|
.modal-content-leave-active {
|
||||||
|
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.modal-content-enter-from,
|
||||||
|
.modal-content-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup>
|
||||||
|
import { messageState, provideMessage } from "@/composables/useMessage";
|
||||||
|
|
||||||
|
provideMessage();
|
||||||
|
|
||||||
|
const MESSAGE_THEME = {
|
||||||
|
success: {
|
||||||
|
containerClass: "bg-[#10AD5D] text-white",
|
||||||
|
iconClass: "icon-[dashicons--yes]",
|
||||||
|
iconExtraClass: "",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
containerClass: "bg-[#D84C4C] text-white",
|
||||||
|
iconClass: "",
|
||||||
|
iconExtraClass: "",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
containerClass: "bg-[#F08A24] text-white",
|
||||||
|
iconClass: "",
|
||||||
|
iconExtraClass: "",
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
containerClass: "bg-[#3a3a3a] text-white",
|
||||||
|
iconClass: "icon-[mingcute--loading-fill]",
|
||||||
|
iconExtraClass: "animate-spin",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveTheme(type) {
|
||||||
|
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
|
||||||
|
<Transition name="message-slide" mode="out-in">
|
||||||
|
<div
|
||||||
|
v-if="messageState.current"
|
||||||
|
:key="messageState.current.id"
|
||||||
|
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
|
||||||
|
:class="resolveTheme(messageState.current.type).containerClass"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="resolveTheme(messageState.current.type).iconClass"
|
||||||
|
class="text-[14px]"
|
||||||
|
:class="[
|
||||||
|
resolveTheme(messageState.current.type).iconClass,
|
||||||
|
resolveTheme(messageState.current.type).iconExtraClass,
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-slide-enter-active,
|
||||||
|
.message-slide-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-slide-enter-to,
|
||||||
|
.message-slide-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/ui/Button.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: "提示" },
|
||||||
|
content: { type: String, default: "" },
|
||||||
|
confirmText: { type: String, default: "确定" },
|
||||||
|
cancelText: { type: String, default: "取消" },
|
||||||
|
showCancel: { type: Boolean, default: true },
|
||||||
|
confirmDisabled: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit("confirm");
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit("cancel");
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMaskClick() {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-mask">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4 "
|
||||||
|
@click.self="onMaskClick"
|
||||||
|
>
|
||||||
|
<Transition name="modal-content">
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||||
|
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||||
|
<h3 class="mb-3 text-base font-medium text-white">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-5 max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-[#a3a3a3]">
|
||||||
|
{{ content }}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button v-if="showCancel" variant="default" @click="handleCancel">{{ cancelText }}</Button>
|
||||||
|
<Button variant="primary" :disabled="confirmDisabled" @click="handleConfirm">{{ confirmText }}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-mask-enter-active,
|
||||||
|
.modal-mask-leave-active {
|
||||||
|
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
.modal-mask-enter-from,
|
||||||
|
.modal-mask-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-enter-active,
|
||||||
|
.modal-content-leave-active {
|
||||||
|
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.modal-content-enter-from,
|
||||||
|
.modal-content-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<script setup>
|
||||||
|
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: "" },
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
placeholder: { type: String, default: "请选择" },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
border: { type: Boolean, default: true },
|
||||||
|
ariaLabel: { type: String, default: "" },
|
||||||
|
buttonClass: { type: String, default: "" },
|
||||||
|
menuClass: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "change", "blur"]);
|
||||||
|
|
||||||
|
const rootRef = ref(null);
|
||||||
|
const buttonRef = ref(null);
|
||||||
|
const menuRef = ref(null);
|
||||||
|
const optionRefs = ref([]);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const activeIndex = ref(-1);
|
||||||
|
const menuStyle = ref({});
|
||||||
|
|
||||||
|
const normalizedOptions = computed(() => props.options.map((option) => {
|
||||||
|
if (typeof option === "string") {
|
||||||
|
return { label: option, value: option };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: option?.label ?? option?.value ?? "",
|
||||||
|
value: option?.value ?? "",
|
||||||
|
icon: option?.icon ?? option?.iconClass ?? "",
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
|
||||||
|
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
|
||||||
|
|
||||||
|
function setOptionRef(el, index) {
|
||||||
|
if (el) {
|
||||||
|
optionRefs.value[index] = el;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete optionRefs.value[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusActiveOption() {
|
||||||
|
nextTick(() => {
|
||||||
|
const option = optionRefs.value[activeIndex.value];
|
||||||
|
option?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
if (props.disabled || isOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = true;
|
||||||
|
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
||||||
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||||
|
nextTick(() => {
|
||||||
|
updatePosition();
|
||||||
|
focusActiveOption();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu({ restoreFocus = false } = {}) {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = false;
|
||||||
|
activeIndex.value = -1;
|
||||||
|
optionRefs.value = [];
|
||||||
|
menuStyle.value = {};
|
||||||
|
|
||||||
|
if (restoreFocus) {
|
||||||
|
nextTick(() => buttonRef.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("blur");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
if (isOpen.value) {
|
||||||
|
closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(option) {
|
||||||
|
if (!option || option.value === props.modelValue) {
|
||||||
|
closeMenu({ restoreFocus: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("update:modelValue", option.value);
|
||||||
|
emit("change", option.value);
|
||||||
|
closeMenu({ restoreFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActiveIndex(step) {
|
||||||
|
if (!normalizedOptions.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen.value) {
|
||||||
|
openMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = normalizedOptions.value.length;
|
||||||
|
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
|
||||||
|
activeIndex.value = (current + step + total) % total;
|
||||||
|
focusActiveOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleButtonKeydown(event) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveIndex(1);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
moveActiveIndex(-1);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
toggleMenu();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
if (isOpen.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOptionKeydown(event, option, index) {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex.value = index;
|
||||||
|
moveActiveIndex(1);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex.value = index;
|
||||||
|
moveActiveIndex(-1);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
selectOption(option);
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
event.preventDefault();
|
||||||
|
closeMenu({ restoreFocus: true });
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
closeMenu();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event) {
|
||||||
|
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!buttonRef.value || !menuRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
computePosition(buttonRef.value, menuRef.value, {
|
||||||
|
placement: "bottom-start",
|
||||||
|
middleware: [
|
||||||
|
offset(6),
|
||||||
|
flip({ padding: 12 }),
|
||||||
|
shift({ padding: 12 }),
|
||||||
|
size({
|
||||||
|
apply({ rects, elements, availableHeight }) {
|
||||||
|
Object.assign(elements.floating.style, {
|
||||||
|
minWidth: `${rects.reference.width}px`,
|
||||||
|
maxHeight: `${Math.max(availableHeight, 160)}px`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
padding: 12,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
menuStyle.value = {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPostEffect((cleanup) => {
|
||||||
|
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
|
||||||
|
|
||||||
|
cleanup(() => {
|
||||||
|
stopAutoUpdate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.modelValue, () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
||||||
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="relative">
|
||||||
|
<button
|
||||||
|
ref="buttonRef"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="flex h-9 items-center rounded-[6px] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:class="[
|
||||||
|
border
|
||||||
|
? 'w-full justify-between gap-2 border border-[#3f3f3f] focus:border-[#10AD5D]'
|
||||||
|
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35',
|
||||||
|
buttonClass,
|
||||||
|
]"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:aria-label="ariaLabel || undefined"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
@click="toggleMenu"
|
||||||
|
@keydown="handleButtonKeydown"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex min-w-0 items-center gap-2"
|
||||||
|
:class="[
|
||||||
|
border ? 'flex-1' : 'shrink-0',
|
||||||
|
selectedOption
|
||||||
|
? (border ? 'text-[#e5e5e5]' : 'text-current')
|
||||||
|
: 'text-[#7b7b7b]',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
||||||
|
<span class="truncate">{{ selectedLabel }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none center-row transition-transform duration-200"
|
||||||
|
:class="[border ? 'text-[#8f8f8f]' : 'text-current', isOpen ? 'rotate-180' : '']"
|
||||||
|
>
|
||||||
|
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150 ease-out"
|
||||||
|
enter-from-class="translate-y-1 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-1 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="menuRef"
|
||||||
|
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
|
||||||
|
:class="menuClass"
|
||||||
|
:style="menuStyle"
|
||||||
|
>
|
||||||
|
<ul role="listbox" class="overflow-y-auto py-1">
|
||||||
|
<li v-for="(option, index) in normalizedOptions" :key="option.value">
|
||||||
|
<button
|
||||||
|
:ref="(el) => setOptionRef(el, index)"
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
|
||||||
|
:class="[
|
||||||
|
option.value === modelValue
|
||||||
|
? 'bg-[#10AD5D]/15 text-[#10d06f]'
|
||||||
|
: 'text-[#e5e5e5] hover:bg-[#303030]',
|
||||||
|
activeIndex === index ? 'bg-[#303030]' : '',
|
||||||
|
]"
|
||||||
|
:aria-selected="option.value === modelValue"
|
||||||
|
tabindex="0"
|
||||||
|
@click="selectOption(option)"
|
||||||
|
@mouseenter="activeIndex = index"
|
||||||
|
@keydown="handleOptionKeydown($event, option, index)"
|
||||||
|
>
|
||||||
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
|
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
||||||
|
<span class="truncate">{{ option.label }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
enabled: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
busy: { type: Boolean, default: false },
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
enabledText: { type: String, default: "已开启" },
|
||||||
|
disabledText: { type: String, default: "已关闭" },
|
||||||
|
busyText: { type: String, default: "切换中..." },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["change"]);
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
if (props.disabled || props.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("change", !props.enabled);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4"
|
||||||
|
:class="compact ? 'py-0' : 'py-1'"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col" :class="compact ? 'gap-[2px]' : 'gap-1'">
|
||||||
|
<div :class="compact ? 'text-[12px]' : 'text-sm'" class="font-medium text-white">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="description"
|
||||||
|
:class="compact ? 'text-[11px] leading-[16px]' : 'text-xs'"
|
||||||
|
class="text-[#a3a3a3]"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
|
||||||
|
enabled ? 'text-[#10AD5D]' : 'text-[#a3a3a3]',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ busy ? busyText : enabled ? enabledText : disabledText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="enabled"
|
||||||
|
:disabled="disabled || busy"
|
||||||
|
class="relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-55 focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35"
|
||||||
|
:class="enabled ? 'bg-[#10AD5D]' : 'bg-[rgba(255,255,255,0.22)]'"
|
||||||
|
@click="handleToggle"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-[2px] top-[2px] inline-flex h-[18px] w-[18px] rounded-full bg-white shadow-[0_2px_5px_rgba(0,0,0,0.22)] transition-all duration-200 ease-out"
|
||||||
|
:class="enabled ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||||
|
import copyTextToClipboard from "copy-text-to-clipboard";
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watchPostEffect } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
content: { type: String, default: "" },
|
||||||
|
copyable: { type: Boolean, default: false },
|
||||||
|
copyText: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const HIDE_DELAY_MS = 300;
|
||||||
|
const COPY_RESET_DELAY_MS = 1500;
|
||||||
|
const triggerRef = ref(null);
|
||||||
|
const tooltipRef = ref(null);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const tooltipStyle = ref({});
|
||||||
|
const copied = ref(false);
|
||||||
|
let hideTimer = null;
|
||||||
|
let copyResetTimer = null;
|
||||||
|
|
||||||
|
const copyValue = computed(() => String(props.copyText || props.content || "").trim());
|
||||||
|
const hasContent = computed(() => !!props.content || !!slots.default);
|
||||||
|
const showCopyButton = computed(() => props.copyable && !!copyValue.value);
|
||||||
|
|
||||||
|
function showTooltip() {
|
||||||
|
if (!hasContent.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearHideTimer();
|
||||||
|
isOpen.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip() {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHideTimer() {
|
||||||
|
if (hideTimer) {
|
||||||
|
window.clearTimeout(hideTimer);
|
||||||
|
hideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCopyResetTimer() {
|
||||||
|
if (copyResetTimer) {
|
||||||
|
window.clearTimeout(copyResetTimer);
|
||||||
|
copyResetTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHideTooltip() {
|
||||||
|
clearHideTimer();
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
hideTooltip();
|
||||||
|
hideTimer = null;
|
||||||
|
}, HIDE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!triggerRef.value || !tooltipRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
computePosition(triggerRef.value, tooltipRef.value, {
|
||||||
|
placement: "top",
|
||||||
|
middleware: [
|
||||||
|
offset(10),
|
||||||
|
flip({ padding: 12 }),
|
||||||
|
shift({ padding: 12 }),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
tooltipStyle.value = {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (!copyValue.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyTextToClipboard(copyValue.value);
|
||||||
|
copied.value = true;
|
||||||
|
clearCopyResetTimer();
|
||||||
|
copyResetTimer = window.setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
copyResetTimer = null;
|
||||||
|
}, COPY_RESET_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPostEffect((cleanup) => {
|
||||||
|
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition);
|
||||||
|
cleanup(() => {
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearHideTimer();
|
||||||
|
clearCopyResetTimer();
|
||||||
|
hideTooltip();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="inline-flex">
|
||||||
|
<button
|
||||||
|
ref="triggerRef"
|
||||||
|
type="button"
|
||||||
|
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
|
||||||
|
@mouseenter="showTooltip"
|
||||||
|
@mouseleave="scheduleHideTooltip"
|
||||||
|
@focus="showTooltip"
|
||||||
|
@blur="scheduleHideTooltip"
|
||||||
|
>
|
||||||
|
<span class="icon-[mdi--information-outline] text-[14px]"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="tooltipRef"
|
||||||
|
class="fixed z-[10000] flex max-h-[320px] max-w-[420px] flex-col overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#202020] px-3 py-2 text-left text-[12px] leading-relaxed text-[#d4d4d4] shadow-[0_12px_32px_rgba(0,0,0,0.45)]"
|
||||||
|
:style="tooltipStyle"
|
||||||
|
@mouseenter="showTooltip"
|
||||||
|
@mouseleave="scheduleHideTooltip"
|
||||||
|
>
|
||||||
|
<div v-if="showCopyButton" class="mb-2 flex shrink-0 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="center-row gap-1 rounded-[6px] border border-[#3f3f3f] bg-[#272727] px-2 py-1 text-[11px] text-[#d4d4d4] transition-colors duration-150 hover:border-[#4c4c4c] hover:bg-[#2f2f2f]"
|
||||||
|
@click="handleCopy"
|
||||||
|
>
|
||||||
|
<span :class="copied ? 'icon-[mdi--check]' : 'icon-[mdi--content-copy]'" class="text-[13px]"></span>
|
||||||
|
<span>{{ copied ? "已复制" : "拷贝" }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 overflow-auto break-words">
|
||||||
|
<slot>
|
||||||
|
<div class="whitespace-pre-wrap">{{ content }}</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||