Compare commits

...
Author SHA1 Message Date
leookun 6170778de9 feat: add update indicators and stabilize Cursor streams 2026-08-24 05:24:07 +08:00
leookun 0f23a9a9c3 feat: add configurable Cursor TAB routing 2026-08-24 04:35:28 +08:00
leookun 8a4fc5076a fix token usage average 2026-08-24 03:49:57 +08:00
leookun b5199de6f1 Merge branch 'refactor/0.1.0-beta' 2026-08-24 03:46:56 +08:00
leookun b74291af79 stabilize cursor request context and release workflow 2026-08-24 03:44:56 +08:00
leookun 2075b61763 release desktop beta 0.1.0-beta.1 2026-08-24 03:22:18 +08:00
leookun 03075952a6 feat(i18n): add i18n support 2026-08-24 03:08:29 +08:00
leokun 4c2efb05c2 Merge pull request #304 from leookun/refactor/0.1.0-beta
Refactor/0.1.0-beta
2026-08-24 02:54:36 +08:00
leookun 2abffcc835 merge main into refactor after Tauri rewrite 2026-08-24 02:53:35 +08:00
leookun 4053a7fb20 refactor: rebuild desktop app with Tauri 2026-08-24 02:49:00 +08:00
leokun 564f2bdcae Merge pull request #313 from leookun/codex/main-worktree
Codex/main worktree
2026-08-20 14:00:13 +08:00
leookun 77c8e31a09 update prompt 2026-08-20 13:59:31 +08:00
leookun eb68375306 update prompt 2026-08-20 12:56:49 +08:00
leokun e9d3845de0 Merge pull request #295 from Sxuan-Coder/feat/config-import-export
feat(config): 支持完整配置导入导出
2026-08-19 11:36:43 +08:00
leokun d4bfa8b938 Merge pull request #308 from Sxuan-Coder/fix/replay-dangling-tool-call-output
fix: 修复工具调用与结果之间穿插文本时,调用被误删导致 Responses API 400(#307)
2026-08-19 11:36:29 +08:00
leokun dead4477be Merge pull request #306 from ProtectCookies/main
fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
2026-08-19 11:36:02 +08:00
上玄 b7f8237f3b fix: keep tool call in provider sanitize when text interleaves call and result
trimDanglingAssistantToolCalls in the provider router held the same
consecutive-tool-message assumption as the replay projector: when a
model emits function_call items before explanation text within one
response (e.g. gpt-5.3-codex-spark), the sanitized sequence becomes
assistant[tool_call] -> assistant[text] -> tool[result] and responded
calls were stripped at the provider boundary even though the projector
already replayed them correctly. The orphan tool result then forced the
Responses adapter to synthesize a placeholder function_call with empty
arguments, degrading the arguments the model sees on in-loop requests.

Widen the response collection window to skip interleaved plain
assistant text messages and drop orphan tool results, mirroring the
projector fix.
2026-08-14 19:59:47 +08:00
上玄 5d04b5b08b fix: keep tool call replay when assistant text interleaves call and result
trimReplayDanglingAssistantToolCalls only collected tool results that
immediately followed the assistant tool-call message. Models such as
gpt-5.3-codex-spark may emit the function_call item before the
explanation text within one response, so history replay order becomes
assistant[tool_call] -> assistant[text] -> tool[result]. The call was
misjudged as dangling and stripped while the tool result survived,
producing a function_call_output without a matching function_call that
the Responses API rejects with 400.

- widen the response collection window to skip interleaved plain
  assistant text messages, and drop orphan tool results in the same pass
- synthesize a placeholder function_call (or drop the output when the
  tool name is unknown) in normalizeOpenAIResponsesInput so conversations
  already persisted with corrupted history can resume
2026-08-14 16:51:45 +08:00
杨超 a90c8d9560 fix(network): 修复proto打包报错问题 2026-08-14 14:13:43 +08:00
杨超 e9dc2e098d fix(network): 修复长耗时请求被误报「Network disconnected」中断的问题
客户端扩展在慢请求(如提交信息生成)开始约 10 秒后会探测
NetworkService/IsConnected,非 200 应答即判定断网并中止进行中的请求。
此前该探测落入通配路由返回 404,导致模型仍在正常返回时界面先报断连。

- host.go: 注册 IsConnected 精确路由,恒定以 200 + 空 IsConnectedResponse 应答
- client.go: newProtoMessage 补齐 aiserver.v1.IsConnectedResponse 类型,
  避免 mock 编码失败导致探测返回 502
- state_db.go: 新增强开 statsig gate 机制(cursorStateEnabledStatsigGates),
  强制启用 disable_network_change_monitor_local 从源头关闭该探测;
  disableCursorStatsigGates 泛化为 syncCursorStatsigGateOverrides
2026-08-14 12:19:47 +08:00
leokun a3ec2a0dfc release: 0.0.48 2026-08-12 20:32:19 +08:00
leokun 38472a3c63 Merge pull request #299 from leookun/fix/issue-298-per-user-ca
Fix shared root CA by generating one per installation
2026-08-12 20:11:43 +08:00
leokun 2b8d1c9e3d fix: generate a per-installation root CA 2026-08-12 20:04:29 +08:00
上玄 3fc04eeb4f feat(config): 支持完整配置导入导出 2026-08-12 09:34:02 +08:00
leokun 7a67e76617 Merge pull request #270 from zfscgy/feat/image-read
Feat/image read: 本地文件读取工具支持读取图片
2026-08-12 01:22:35 +08:00
leookun 08ce12aa55 Merge branch 'main' into codex/pr270-content-addressed-read-images
# Conflicts:
#	frontend/src/i18n/generated/catalog.json
2026-08-12 01:14:24 +08:00
leookun 8f8d28880d feat(forwarder): persist read images by content hash 2026-08-12 01:10:14 +08:00
leokun 988ba63d40 Merge pull request #291 from Sxuan-Coder/feat/optional-reasoning-effort
fix(model): 支持不设置 reasoning effort
2026-08-11 23:44:52 +08:00
上玄 edd59dc684 fix(model): 支持不设置推理强度 2026-08-11 21:18:10 +08:00
leokun da5fa34a4d 更新 README-CN.md 2026-08-10 23:19:34 +08:00
leokun 4bd2359282 Update README-CN.md 2026-08-10 23:14:14 +08:00
leokun 7838ffc6a2 Update README.md 2026-08-10 23:13:48 +08:00
leokun ee30775be1 Update Chinese translation link in README 2026-08-10 23:10:17 +08:00
leokun 80a5093aa9 Update README.md 2026-08-10 23:09:41 +08:00
leokun b9742b1667 Update README-CN.md 2026-08-10 23:09:04 +08:00
leokun 7d3e74be59 Update README.md 2026-08-10 23:08:36 +08:00
leokun 1285bf9d62 Update README-CN.md 2026-08-10 23:05:44 +08:00
leokun 7a724595eb Update README.md 2026-08-10 23:05:31 +08:00
leokun 00aec40e50 Update README.md 2026-08-10 23:03:50 +08:00
leokun c10a2d475d Merge pull request #285 from leookun/release/0.0.47
release: 0.0.47
2026-08-10 22:56:00 +08:00
leookun 4864d3675b release: 0.0.47 2026-08-10 22:55:16 +08:00
leokun 3f95318a49 Merge pull request #284 from leookun/fix/compress
Enhance checkpoint handling and error management in forwarder
2026-08-10 22:34:40 +08:00
leookun 9373e57ebf Enhance checkpoint handling and error management in forwarder
- Added flushing of assistant text during provider completion to ensure no output is lost on transport failure.
- Updated checkpoint blob synchronization tests to validate behavior under various conditions, including terminal and non-terminal states.
- Introduced new functions for managing checkpoint terminal actions, improving clarity and maintainability of the code.
- Implemented additional tests for imported blob handling and conversation state restoration, ensuring robustness in data integrity across operations.
2026-08-10 22:25:37 +08:00
leokun f1992b0cfe Merge pull request #281 from jiah0231/fix/openai-reasoning-summary
fix: request OpenAI Responses reasoning summaries
2026-08-10 10:24:45 +08:00
haoge0211 67a9c27931 fix: request OpenAI Responses reasoning summaries 2026-08-08 20:48:07 +08:00
leookun 3cf8bdbc3c docs: make English README the default
Keep the Chinese documentation available through a dedicated language link.
2026-08-08 16:22:12 +08:00
leokun 684953a80b Merge pull request #279 from leookun/fix/cli-model-name
refactor: update model details handling in CLI
2026-08-08 15:46:22 +08:00
leookun 297b56aed0 refactor: update model details handling in CLI
- Renamed test function to better reflect its purpose.
- Enhanced model details structure by adding DisplayName and DisplayNameShort fields in buildCLIModelDetails.
- Updated test cases to validate the new fields and ensure correct functionality.
2026-08-08 15:45:59 +08:00
leookun 2393df1cb8 Remove Chinese README file and update English README links for consistency 2026-08-08 01:00:24 +08:00
leookun 7d622dd039 Enhance README and UI components with improved user guidance and account identifier masking
- Updated README.md to provide clearer project information, features, and quick start instructions.
- Added a function to mask the user's account identifier in CursorAccountCard.vue for enhanced privacy.
- Adjusted localization files to remove outdated entries and improve clarity across multiple languages.
- Refactored MainLayout.vue to streamline author information handling and improve user experience.
2026-08-08 00:54:02 +08:00
leokun 1a7a20c519 Merge pull request #277 from leookun/feat/shell-tool-streaming
feat: add shell tool call delta message handling
2026-08-07 22:40:56 +08:00
郑非 85a43115c7 read image tests 2026-08-06 21:06:13 +08:00
郑非 b475166ba8 Support read image 2026-08-06 21:05:08 +08:00
798 changed files with 104342 additions and 140456 deletions
View File
-356
View File
@@ -1,356 +0,0 @@
---
name: coding-guidance
description: 本地模式实现指南
---
当用户在处理本地模式的时候,使用此指南
先遵守这个约束:
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`:
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
- 需要确认当前到底是哪一个 app 副本在运行
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
- 需要对照已安装客户端行为与本仓库实现差异
本地模式协议需要优先核对这些文件:
- proto/agent_v1.proto
- proto/aiserver_v1.proto
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
- 常用格式化路径:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
## 本仓库已固定的会话承接规则
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq`、`next_entry_seq`、`context_version`、`current_todos`、`current_plans`、`latest_request_prefix`、`last_provider_call`。
- `context.json.items` 保存 append-only 的语义历史 entries;provider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash,resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
- `summary.json`、`replay.json`、`runtime.json`、`request.json`、`conversation.json`、`entries.jsonl`、`turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json` 的 `next_entry_seq`、`next_turn_seq`、`context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
# 已确认结论
## 1. `AgentServerMessage` 不是统一都要“回复完成”
要按 `oneof message` 分类看:
- `exec_server_message`
- 这是服务端发给客户端的“执行请求”。
- 客户端需要显式回 `ExecClientMessage`。
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
- `stream_close`
- `throw`
- `heartbeat`
- `interaction_query`
- 这是服务端发给客户端的“交互请求”。
- 客户端需要显式回 `InteractionResponse`。
- `interaction_update`
- 这是展示/状态更新消息,通常不需要客户端回包。
- `conversation_checkpoint_update`
- 这是 checkpoint 同步消息,通常不需要客户端回包。
- `kv_server_message`
- 这是 KV 同步消息,通常不需要客户端回包。
- `exec_server_control_message`
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
在 `cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`:
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`。
## 2.1 更具体的客户端侧结论
从 `cursor-always-local/dist/main.js` 里能直接确认:
- `AgentServerMessage` 的下行类型里有:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `conversation_checkpoint_update`
- `interaction_query`
- `AgentClientMessage` 的上行类型里有:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
- `exec_server_message`
-> 客户端执行本地工具
-> 产出 `exec_client_message`
-> 以及可选 `exec_client_control_message`
- `interaction_query`
-> 客户端展示或处理交互
-> 产出 `interaction_response`
- 其他下行消息
-> 一般只更新 UI / checkpoint /流状态
-> 不会自然地产生一个“完成 ack”
## 2.2 `exec_server_message` 常见的客户端回包形态
客户端协议模型里已确认这些回包类型:
- `ExecClientMessage`
- 正常结果面
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream` 等
- `ExecClientControlMessage`
- 控制面
- 包括:
- `stream_close`
- `throw`
- `heartbeat`
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`:
- 有些工具只回一次结果面消息
- shell 之类的流式工具会混合回:
- 多次 `shell_stream`
- 以及控制消息(例如 `stream_close` / `heartbeat`)
## 2.3 `exec_server_message` 的完整回包形态
事实依据:
- `proto/agent_v1.proto`
- `ExecServerMessage.oneof message`
- `ExecClientMessage.oneof message`
- `ExecClientControlMessage.oneof message`
- `cursor-always-local/dist/main.js`
- bundle 内含同名 proto 模型
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
`ExecServerMessage` 的 `message` 分支与 `ExecClientMessage` 的 `message` 分支是一一对应的:
- `shell_args`
-> `shell_result`
- `write_args`
-> `write_result`
- `delete_args`
-> `delete_result`
- `grep_args`
-> `grep_result`
- `read_args`
-> `read_result`
- `ls_args`
-> `ls_result`
- `diagnostics_args`
-> `diagnostics_result`
- `request_context_args`
-> `request_context_result`
- `mcp_args`
-> `mcp_result`
- `shell_stream_args`
-> `shell_stream`
- `background_shell_spawn_args`
-> `background_shell_spawn_result`
- `list_mcp_resources_exec_args`
-> `list_mcp_resources_exec_result`
- `read_mcp_resource_exec_args`
-> `read_mcp_resource_exec_result`
- `fetch_args`
-> `fetch_result`
- `record_screen_args`
-> `record_screen_result`
- `computer_use_args`
-> `computer_use_result`
- `write_shell_stdin_args`
-> `write_shell_stdin_result`
- `execute_hook_args`
-> `execute_hook_result`
- `subagent_args`
-> `subagent_result`
所有这些结果面回包都带:
- `id`
- `exec_id`
服务端匹配时通常优先用:
1. `exec_id`
2. `id`
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
除了结果面回包外,客户端还可能回控制面消息:
- `stream_close`
- 表示当前 exec 流已关闭
- 只有 `id`
- `throw`
- 表示执行异常
- 只有 `id` + `error` + 可选 `stack_trace`
- `heartbeat`
- 表示执行过程中的心跳
- 只有 `id`
### 关键理解
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
- 它是跨 exec 通用的控制面回包
- 因此调查时必须同时看两类上行:
- `ExecClientMessage`
- `ExecClientControlMessage`
### 调查规则
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
- 收到对应的 `ExecClientMessage`
- 或收到 `ExecClientControlMessage.throw`
- 对流式 exec,还要看:
- 是否有多次增量 `ExecClientMessage`
- 是否最终有 `stream_close`
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
排查时优先搜索这些关键字:
## 3. forwarder 状态机实现规则
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
### 3.1 resume 必须按 provider pass 隔离
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
- resume 请求必须带来源 pass,至少要能区分:
- 当前 pass 的结果触发的合法续跑
- 上一轮工具终态晚到造成的陈旧 resume
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
### 3.2 工具晚到是常态,只能影响所属 pass
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
### 3.3 pending exec 必须严格按 id 匹配
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
- `exec_id`
- `message_id`
进行匹配。
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
- 优先看 `RecentCompletedExecs` 做幂等忽略
- 不要把它重新落到当前轮的 pending 上
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
- 同一个 `request_id` 在 `[DONE]` 后又出现新的 `model_call_id`
- `turns/<n+1>/request.json` 与 `turns/<n>/request.json` messages 几乎完全相同
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
优先核对:
- `ProviderPassCount`
- resume 请求的来源 pass
- `PendingExec.ProviderPass`
- `selectPendingExec` 是否存在跨轮误匹配
- `startYieldingInputsToTheServer`
- `bidiAppend({requestId:A,appendSeqno`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
## 3. 对本地模式最重要的协议理解
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
- 如果客户端不回对应结果,服务端 pending 不会收口
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
- 一般不要求客户端再回一个“完成”消息
## 4. 调查本地模式时的优先顺序
1. 先确认收到的 `AgentServerMessage` 是哪一类
2. 如果是 `exec_server_message`
- 查客户端是否回了 `ExecClientMessage`
- 查是否只回了 `stream_close` 但没有真正结果
- 查 `exec_id` / `id` 是否匹配
3. 如果是 `interaction_query`
- 查客户端是否回了 `InteractionResponse`
4. 如果是 `conversation_checkpoint_update`
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
- 不要误以为它本身需要回 ack
## 5. 对服务端实现的直接要求
- 服务端必须区分“请求型下行”和“通知型下行”
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
- 对请求型消息,必须在本地状态机里维护 pending:
- `PendingExec`
- `PendingInteraction`
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
### 5.1 checkpoint 投影必须幂等且只有一个事实源
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
@@ -1,4 +0,0 @@
interface:
display_name: "本地模式实现指南"
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
@@ -1,107 +0,0 @@
---
name: cursor-app-formatted
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
---
# Cursor App Formatted Snapshot
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
## Invariants
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
## Preferred Investigation Flow
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
3. Prefer stable formatted paths for line references and control-flow reading:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
## Git Ignore Rule
Ensure `.gitignore` contains:
```gitignore
.cursor-app-formatted/
```
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
## Snapshot Generation Workflow
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
```bash
set -euo pipefail
SNAPSHOT=.cursor-app-formatted
SOURCE=/Applications/Cursor.app/Contents/Resources/app
rm -rf "$SNAPSHOT"
mkdir -p "$SNAPSHOT"
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
/usr/bin/shasum -a 256 \
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
> "$SNAPSHOT/source-sha256.txt"
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
| /usr/bin/sed "s#^$SOURCE/##" \
| while IFS= read -r rel; do
/usr/bin/shasum -a 256 "$SOURCE/$rel"
done >> "$SNAPSHOT/source-sha256.txt"
```
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
```bash
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
EMPTY_IGNORE="$(mktemp)"
trap 'rm -f "$EMPTY_IGNORE"' EXIT
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
```
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
## Validation
After generation, verify the snapshot is ignored and key files are readable:
```bash
git status --short --ignored | rg '\.cursor-app-formatted'
wc -l \
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
```
Useful investigation check:
```bash
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
```
@@ -1,97 +0,0 @@
---
name: cursor-client-e2e-debugging
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
---
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
当用户只给一个 UUID / id,希望反查它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
## 首要约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`。
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite`、`conversation.json`、`turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
## 先做路由判断
- `history + logs` 反查层
- 现象:用户发来一个 id,要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`;需要从 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json` 追运行状态和语义历史。
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- `provider replay / debug` 层
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
- `cursor-agent` 层
- 现象:`CursorAgentProvider`、`ClaudeSDKClient`、`AnthropicProxy`、`registerAgentProvider`、`InteractionUpdate` 映射、模型桥接异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- `cursor-always-local` / 本地模式协议层
- 现象:`BidiAppend`、`RunSSE`、`AgentServerMessage`、`ExecClientMessage`、`InteractionResponse`、live checkpoint / pending 收口异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- 客户端 bundle 只读定位层
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
## 当前工作流
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items`、`state.json` 和 `logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`。
2. 一旦拿到 `conversationId`,同时看两份事实源:
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix`、`last_provider_call`。
- `history/<conversationId>/context.json`:append-only 的语义历史 entries;prompt replay 由 `ProjectPromptReplay()` 从这里投影。
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json`,`AppendLLMResponseChunk` 是 no-op,`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
4. 再确认故障主要落在 `cursor-agent`、`cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body:
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
- 通过 id 反查拿到 `conversationId`、`requestId`、`modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`。
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
7. 如果用户要核对 prefix cache / cache hit:
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
- 它读取当前 `history/<conversationId>/state.json` 与 `history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
8. 如果需要对照已安装 app 与仓库行为:
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
- 先确认实际运行的 app 副本和目标 bundle 路径。
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
9. 如果证据显示问题更像是客户端 bundle 行为差异:
- 记录具体文件、符号、日志和协议证据链。
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
## 约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId`、`requestId`、`modelCallId`、`toolCallId`。
- 不要把 `history/<conversationId>/state.json` 和 `history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
- 不要再依赖 `agent_request_runs`、`agent_conversations`、`agent_history_entries`、`protocol_traces`、`data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
## 模型渠道规则
- 模型渠道唯一性不再由 `modelID` 决定。
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
- resolver 仍兼容 legacy 渠道 ID:`baseURL + modelID + apiKey + displayName`。
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`。
## 参考加载规则
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
- 文件地图:读 [references/file-map.md](references/file-map.md)
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
## 自带脚本
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG`、`REQUEST_ID`、`MODEL_CALL_ID`
@@ -1,177 +0,0 @@
# backend/store 日志反查
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
## 当前固定路径
当前用户机器上的固定助手根目录:
- `~/.cursor-local-assistant-v2`
当前最关键的是三类内容:
- `history/<conversationId>/state.json`
- 会话元数据与当前状态。
- 重点字段:`request_id` `conversation_id`、`root_conversation_id`、`parent_conversation_id`、`parent_tool_call_id`、`mode`、`current_loop_status`、`current_request_id`、`current_turn_seq`、`context_version`、`next_turn_seq`、`next_entry_seq`、`latest_request_prefix`、`last_provider_call`、`current_todos`、`current_plans`、token / compaction 字段。
- `history/<conversationId>/context.json`
- append-only 语义历史。
- 重点字段:`version`、`items[]`。
- `items[]` 中每个 entry 通常有 `seq`、`turn_seq`、`request_id`、`role`、`kind`、`tool_call_id`、`parent_tool_call_id`、`payload`、`created_at`。
- `history/usage.json`
- provider call 与 turn usage 聚合。
- 重点字段:`totals`、`daily`、`recent_events`、`event_index`。
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
当前实现不再支持:
- DB-backed store / searchable conversation memory
- HTTP/protocol trace debug UI
- `data/data.sqlite` / `protocol_traces`
- `history/<conversationId>/conversation.json`
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
- 根目录或会话目录下的旧 `latest.json`、`summary.json`、`replay.json`、`runtime.json`、`request.json`、`recovery.json`、`entries.jsonl`、数字 turn 目录
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
## 用户发来一个 id 时的固定步骤
### 1. 先判断 id 类型
不要先假设它是 `requestId`。按这个顺序缩小范围:
1. 是否是 `conversationId`
- 检查 `history/<id>/state.json` 和 `history/<id>/context.json` 是否存在。
2. 是否是 `requestId`
- 在 `history/*/state.json` 中查 `current_request_id`、`latest_request_prefix.request_id`、`last_provider_call.request_id`。
- 在 `history/*/context.json` 的 `items[].request_id` 中查。
- 在 `logs/app.log` 中查。
3. 是否是 `modelCallId`
- 在 `state.json` 中查 `latest_request_prefix.model_call_id`、`last_provider_call.model_call_id`。
- 在 `context.json.items[].payload` 中查 `model_call_id`。
- 在 `logs/app.log` 中查 `model_call_id=<id>`。
4. 是否是 `toolCallId` / `exec_id`
- 在 `context.json.items[].tool_call_id`、`items[].payload` 中查。
- 在协议/工具相关日志中查。
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
### 2. 拿到 `conversationId` 后看两份事实源
```bash
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
CONV_ID="<conversation-id>"
ls -la "$HISTORY_ROOT/$CONV_ID"
```
重点检查:
- `state.json`
- `current_loop_status`:`idle`、`running`、`waiting_tool`、`completed`、`canceled`、`provider_error`、`failed`
- `current_request_id`、`current_turn_seq`
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
- `last_provider_call`:最近 provider 状态与错误文本
- `next_entry_seq`、`next_turn_seq`、`context_version`
- `current_todos`、`current_plans`
- `context.json`
- `version` 是否与 `state.context_version` 对齐
- `items[]` 是否按 `seq` 稳定递增
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
- 是否有重复、缺失或顺序异常
- `usage.json`
- 通过 `event_index` 或 `recent_events` 查 request/model-call 相关 usage
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
### 3. Provider 调用证据现在在哪里
当前 provider artifact recorder 的行为:
- `RecordLLMRequest(...)`
- 只缓存当前 provider call 的请求摘要。
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`。
- 不再写 `request.json`。
- `AppendLLMResponseChunk(...)`
- 当前是 no-op。
- 不再写 `sse.jsonl`。
- `RecordLLMSummary(...)`
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`。
- usage 聚合写入 `history/usage.json`。
- 不再写 `summary.json`。
所以 provider 错误排查应优先看:
- `state.last_provider_call`
- `state.latest_request_prefix`
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
- `history/usage.json`
- `logs/app.log`
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
## 这些文件是怎么生成的
### 根路径
- `internal/appdata/paths.go`
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
- `HistoryRootPath()` 为 `~/.cursor-local-assistant-v2/history`
- `UsageFilePath()` 为 `~/.cursor-local-assistant-v2/history/usage.json`
- `LogsRootPath()` 为 `~/.cursor-local-assistant-v2/logs`
### `state.json + context.json`
来源链路:
- `internal/backend/forwarder/file_store.go`
- `CreateConversation`
- `LoadConversation`
- `AppendEntries`
- `SaveConversationWithEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `internal/backend/forwarder/service.go`
- `handleRunIntent` 开始新 loop / turn
- `appendConversationEntries` 追加语义事件
- `internal/backend/forwarder/projector.go`
- `ProjectPromptReplay()` 把 `context.json.items` 投影为 provider messages
稳定结论:
- `state.json` 是当前状态和可变元数据。
- `context.json.items` 是 replayable 语义历史。
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`。
### `usage.json`
来源链路:
- `internal/backend/forwarder/usage_store.go`
- `UsageFileStore.UpsertEvent`
- `UsageFileStore.LookupEvent`
- `internal/backend/forwarder/token_usage.go`
- `internal/historymetrics/`
稳定结论:
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`。
### legacy 清理
来源链路:
- `internal/backend/forwarder/history_maintenance.go`
稳定结论:
- `turns/`、`conversation.json`、`entries.jsonl`、`request.json`、`summary.json` 等都是 legacy artifact。
- 启动后的 history maintenance 会清理这些旧产物。
## 快速判断规则
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`。
- 请求失败时,先看 `state.last_provider_call`、`context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`。
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`。
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
@@ -1,147 +0,0 @@
# 文件地图
## 已安装客户端 bundle
优先核对这些实际运行中的客户端文件:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec`、`cursor-agent-worker`、`cursor-always-local` 及其 `dist/` 文件。
大致归属:
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend`、`RunSSE`、`AgentServerMessage` / `AgentClientMessage` 桥接。
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunk:agent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
- `cursor-agent-worker/dist/main.js`:agent worker 侧后台逻辑。
用户机器上可能还存在其它 app 副本,例如:
- `~/Applications/Cursor Hooked.app`
- `/Applications/Cursor Patched.app`
不要假设哪一个在跑,先看进程路径。
## 当前 backend/store 与 history
当前用户机器上的固定助手目录:
- `~/.cursor-local-assistant-v2/`
重点看:
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/usage.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
- `~/.cursor-local-assistant-v2/logs/app.log`
其中:
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
- `usage.json` 是全局 provider call / turn usage 聚合。
- `conversation.lock` 是会话级文件锁。
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
- legacy artifacts:`conversation.json`、`entries.jsonl`、`turns/`、`request.json`、`summary.json`、`sse.jsonl`、`replay.json`、`runtime.json`、`latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
这些内容的生成入口主要在:
- `internal/appdata/paths.go`
- `internal/backend/host.go`
- `internal/backend/README.md`
- `internal/backend/forwarder/file_store.go`
- `internal/backend/forwarder/history_maintenance.go`
- `internal/backend/forwarder/usage_store.go`
- `internal/backend/forwarder/token_usage.go`
- `internal/backend/forwarder/artifacts.go`
## 本仓库协议与本地模式实现
协议定义:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `proto/from_extensions/agent_v1.proto`
- `proto/from_extensions/aiserver_v1.proto`
扩展快照与提取:
- `proto/extensions-cursor-app/cursor-always-local/package.json`
- `proto/extract_extensions_proto.sh`
- `proto/ext_tool/main.go`
本地后端入口:
- `internal/backend/host.go`
- `internal/backend/server/route.go`
- `internal/backend/server/policy.go`
- `internal/backend/server/local.go`
- `internal/backend/server/config/types.go`
- `internal/backend/server/config/manager.go`
- `internal/backend/server/config/resolver.go`
forwarder 主链路:
- `internal/backend/forwarder/module.go`
- `internal/backend/forwarder/service.go`
- `internal/backend/forwarder/actor.go`
- `internal/backend/forwarder/broker.go`
- `internal/backend/forwarder/events.go`
- `internal/backend/forwarder/compiler.go`
- `internal/backend/forwarder/projector.go`
- `internal/backend/forwarder/provider.go`
- `internal/backend/forwarder/checkpoint_memory.go`
- `internal/backend/forwarder/runtime_summary.go`
协议解码:
- `internal/backend/agent/protocol/inbound.go`
执行桥 / 交互桥:
- `internal/backend/agent/bridge/exec/bridge.go`
- `internal/backend/agent/bridge/interaction/bridge.go`
模型适配:
- `internal/backend/agent/model/router.go`
- `internal/backend/agent/model/openai.go`
- `internal/backend/agent/model/anthropic.go`
- `internal/backend/agent/model/artifacts.go`
- `internal/backend/agent/model/http_error.go`
- `internal/backend/agent/model/tool_call_id.go`
- `internal/modelchannel/identity.go`
- `internal/runtime/local_runtime.go`
Prompt / replay:
- `internal/backend/agent/prompt/engine.go`
- `internal/backend/agent/prompt/replay.go`
- `internal/backend/agent/prompt/content_parts.go`
- `internal/backend/forwarder/prompt_context.go`
- `internal/backend/forwarder/request_context.go`
- `internal/backend/forwarder/reminders.go`
- `internal/backend/forwarder/prompt_guard.go`
## 构建相关参考(只读)
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
- `Taskfile.yml`
- `build/darwin/Taskfile.yml`
- `build/dmg-extras/提示损坏?点我.command`
重点看:
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
@@ -1,87 +0,0 @@
# 已安装客户端只读核对与验证
首要原则:
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
## 1. 先确认实际运行的 app 副本
优先用非交互命令核对:
```bash
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
```
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
## 2. 只读定位目标 bundle 与关键文件
优先定位并读取这些文件,而不是改写它们:
```bash
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
```
重点关注:
- `out/vs/workbench/workbench.desktop.main.js`
- `out/vs/workbench/api/node/extensionHostProcess.js`
- `cursor-always-local/dist/main.js`
- `cursor-always-local/dist/gitWorker.js`
- `cursor-agent-exec/dist/main.js`
- `cursor-agent-exec/dist/*.js`
- `cursor-agent-worker/dist/main.js`
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
## 3. 只读读取 bundle 内容
常用定位关键词:
```bash
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
```
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
如果需要和仓库实现对照,优先同时打开:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `internal/backend/...`
- `internal/runtime/local_runtime.go`
## 4. 验证行为是否命中目标副本
至少做其中两项:
- 进程路径是否是目标 app
- 目标扩展 host 是否起来
- 本地监听端口是否存在
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
常用验证:
```bash
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
```
## 5. 记录证据并输出归因
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
1. 实际运行的 app 路径
2. 命中的 bundle 文件路径与关键符号
3. 对应日志、端口、`history/state.json`、`history/context.json`、`usage.json` 证据
4. 仓库里对应实现的位置
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
@@ -1,153 +0,0 @@
# Provider replay / debug
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
## 证据边界
- `history/<conversationId>/debug/provider.jsonl`
- 最接近 provider 出站边界。
- `event=llm_request` 的 `payload.body` 是最终 provider request body。
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
- `history/<conversationId>/state.json`
- 当前状态和最近 provider 摘要。
- 重点看 `latest_request_prefix`、`last_provider_call`。
- `history/<conversationId>/context.json`
- replayable 语义历史。
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
- `history/<conversationId>/debug/bidi.raw.jsonl`
- 客户端原始上行字节证据。
- `history/<conversationId>/debug/bidi.decoded.jsonl`
- 当前 known-schema 解码后的客户端上行证据。
- `history/<conversationId>/debug/runtime.jsonl`
- 后端把哪些字段挂到 active request / stream 上。
- `history/<conversationId>/debug/runsse.jsonl`
- 后端尝试发回客户端的消息。
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
## 前置条件
- 请求发生时 `config.yaml` 中 `log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`。
- 已经通过 id 反查拿到:
- `conversationId`
- `requestId`
- `modelCallId`
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
如果没有 debug 文件,先回到 `state.json`、`context.json`、`usage.json`、`logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
## 最小流程
1. 定位 provider debug 文件:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
CONV="<conversationId>"
REQ="<requestId>"
MODEL_CALL="<modelCallId>"
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
```
2. 确认 `llm_request` 存在:
```bash
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
' "$REQUEST_LOG"
```
3. 执行通用重放脚本:
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
CHANNEL_NAME="GLM" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
也可以直接传入 provider 配置,避免读取 `config.yaml`:
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
BASE_URL="<provider-base-url>" \
API_KEY="<provider-api-key>" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
4. 查看产物:
- `request.body.json`:抽取出的最终 provider body。
- `response.headers`:HTTP 响应头。
- `response.sse`:SSE 响应体。
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
## 结果判断
- `curl_exit_code != 0`
- 网络、TLS、超时、连接或本机 curl 问题。
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
- HTTP 非 2xx
- provider 网关或鉴权层拒绝。
- 优先看 `response.headers` 和 provider 错误体。
- HTTP 2xx 但 SSE 中有 `event: error`
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
- SSE 正常流式输出
- 原始 provider 请求形状基本可用。
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
## 常见收敛方向
provider 参数错误时,优先检查:
- `model` 是否是目标 endpoint 支持的名称。
- `messages` 是否符合 Anthropic-compatible 形态。
- `system` 是否被目标 provider 支持,或需要改成 message。
- `tools` / `tool_choice` 是否符合目标 provider 方言。
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
- `max_tokens`、`temperature`、`top_p`、`stop_sequences` 是否落在 provider 允许范围内。
## 敏感信息规则
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
## 脚本参数
`scripts/provider-replay.sh` 使用环境变量控制:
- 必填:
- `REQUEST_LOG`
- `REQUEST_ID`
- `MODEL_CALL_ID`
- 可选:
- `BASE_URL`
- `API_KEY`
- `GLM_BASE_URL`
- `GLM_API_KEY`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
- `CHANNEL_NAME`,默认 `GLM`
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
- `MAX_TIME`,默认 `240`
- `ENDPOINT_PATH`,默认 `/v1/messages`
脚本输出四个稳定产物:
- `request.body.json`
- `response.headers`
- `response.sse`
- `replay.meta.json`
@@ -1,223 +0,0 @@
# 搜索词与判断树
## 先判断层级
### `history + logs` / id 反查层
当现象是“用户只给了一个 id”“需要判断它是 `conversationId`、`requestId`、`modelCallId`、`toolCallId`”“要从本地 history 和日志追状态”:
优先搜索:
- `state.json`
- `context.json`
- `usage.json`
- `logs/app.log`
- `conversation_id`
- `current_request_id`
- `request_id`
- `model_call_id`
- `tool_call_id`
- `latest_request_prefix`
- `last_provider_call`
- `current_loop_status`
- `context_version`
- `next_entry_seq`
- `next_turn_seq`
- `LoadConversation`
- `CreateConversation`
- `SaveConversationWithEntries`
- `AppendEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `ProjectPromptReplay`
- `UsageFileStore`
- `UpsertEvent`
- `LookupEvent`
不要再优先搜索或依赖:
- `data.sqlite`
- `protocol_traces`
- `agent_request_runs`
- `conversation.json`
- `entries.jsonl`
- `turns/<n>`
- `request.json`
- `sse.jsonl`
- `summary.json`
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
### `cursor-agent-exec` / `cursor-agent-worker` 层
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
优先在已安装客户端 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
优先搜索:
- `registerAgentProvider`
- `CursorAgentProvider`
- `CursorAgentProviderHandle`
- `ClaudeSDKClient`
- `streamInteractionUpdates`
- `handlePartialMessage`
- `AnthropicProxy`
- `getAnthropicProxyPort`
- `getAnthropicProxyAuthToken`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `InteractionUpdate`
- `checkpoint`
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
### agent window / conversation metadata UI 层
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
优先在已安装客户端主 UI 和 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
优先搜索:
- `shouldShowAgentWindowTitleHelperText`
- `glass_open_agents_titlebar_button`
- `open_agent_window_top`
- `open_agent_window_bottom_convo`
- `glass.enable_open_agent_in_window`
- `NameAgentRequest`
- `NameAgentResponse`
- `UpdateConversationMetadataRequest`
- `UpdateConversationMetadataResponse`
- `CreateTranscriptOverviewRequest`
- `createTranscriptOverview`
- `updateConversationMetadata`
- `conversation_checkpoint_update`
判断规则:
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
### `cursor-always-local` / 协议层
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
优先搜索:
- `BidiTransport`
- `startYieldingInputsToTheServer`
- `BidiAppend`
- `RunSSE`
- `AgentServerMessage`
- `AgentClientMessage`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
- `conversation_checkpoint_update`
### 本仓库 forwarder 层
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
优先搜索:
- `handleRunIntent`
- `driveProvider`
- `startStreamActor`
- `streamCommandEnvelope`
- `handleToolInvocation`
- `handleExecResult`
- `handleExecControl`
- `publishCheckpoint`
- `CheckpointConversation`
- `snapshotCheckpointConversation`
- `appendConversationEntries`
- `OpenExec`
- `OpenQuery`
- `StartStream`
- `deriveConversationLoopState`
- `historyEntryToolCallID`
- `recordProviderUsage`
- `recordTurnUsage`
### provider / 模型适配层
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
优先搜索:
- `StartStream`
- `StreamRequest`
- `ResolvedChannelID`
- `ResolvedChannelName`
- `ProviderModelID`
- `ThinkingEnabled`
- `buildAnthropicThinkingConfig`
- `normalizeAnthropicProviderMessages`
- `normalizeOpenAIProviderMessages`
- `normalizeOpenAIResponsesInput`
- `reasoning_content`
- `ReasoningContent`
- `ReasoningSignature`
- `RecordLLMRequest`
- `RecordLLMSummary`
- `http_error`
- `namespaceToolCallID`
## 快速判断规则
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json`、`history/*/context.json` 和 `logs/app.log`。
- 如果问题是“模型输出语义不对”,先看 `context.json.items` 到 `ProjectPromptReplay()` 的投影,再看 provider request normalization。
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix`、`state.last_provider_call`、`logs/app.log`。
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpoint;checkpoint 是 live state,重启后的事实源是 `state.json + context.json`。
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256;resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`。
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
## 协议关键词
上行:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
下行:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `interaction_query`
- `conversation_checkpoint_update`
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
- `exec_id`
- `id`
- `tool_call_id`
- `request_id`
- `model_call_id`
- pending 收口逻辑
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
- `history/<id>/state.json`
- `history/*/state.json` 的 `current_request_id`、`latest_request_prefix`、`last_provider_call`
- `history/*/context.json` 的 `items[].request_id`、`items[].tool_call_id`、`items[].payload`
- `history/usage.json` 的 `event_index` / `recent_events`
- `logs/app.log`
@@ -1,33 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
const repoRoot = path.resolve(skillRoot, "../../..");
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
const child = spawn("go", args, {
cwd: repoRoot,
stdio: "inherit",
});
child.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`cache-hit-rate.mjs failed: ${message}`);
process.exitCode = 1;
});
child.on("exit", (code, signal) => {
if (typeof code === "number") {
process.exitCode = code;
return;
}
if (signal) {
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
}
process.exitCode = 1;
});
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
Required:
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
REQUEST_ID Provider request_id to replay
MODEL_CALL_ID Provider model_call_id to replay
Optional:
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
MAX_TIME curl max-time seconds. Defaults to 240.
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
REQUEST_LOG="${REQUEST_LOG:-}"
REQUEST_ID="${REQUEST_ID:-}"
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
MAX_TIME="${MAX_TIME:-240}"
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
BODY_FILE="$OUT_DIR/request.body.json"
RESP_FILE="$OUT_DIR/response.sse"
HEADER_FILE="$OUT_DIR/response.headers"
META_FILE="$OUT_DIR/replay.meta.json"
require_value() {
local name="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "缺少 $name。运行 --help 查看用法。" >&2
exit 2
fi
}
read_channel_config() {
local field="$1"
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
import sys
from pathlib import Path
config_path = Path(sys.argv[1]).expanduser()
channel_name = sys.argv[2]
field = sys.argv[3]
if not config_path.exists():
raise SystemExit
lines = config_path.read_text(encoding="utf-8").splitlines()
in_channel = False
for line in lines:
stripped = line.strip()
if stripped.startswith("- displayName:"):
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
continue
if in_channel and stripped.startswith(field + ":"):
print(stripped.split(":", 1)[1].strip().strip('"'))
raise SystemExit
PY
}
require_value "REQUEST_LOG" "$REQUEST_LOG"
require_value "REQUEST_ID" "$REQUEST_ID"
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
if [[ ! -f "$REQUEST_LOG" ]]; then
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
exit 2
fi
if [[ -z "$BASE_URL" ]]; then
BASE_URL="$(read_channel_config baseURL || true)"
fi
if [[ -z "$API_KEY" ]]; then
API_KEY="$(read_channel_config apiKey || true)"
fi
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
mkdir -p "$OUT_DIR"
: > "$HEADER_FILE"
: > "$RESP_FILE"
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
import json
import sys
from pathlib import Path
log_path = Path(sys.argv[1]).expanduser()
request_id = sys.argv[2]
model_call_id = sys.argv[3]
body_path = Path(sys.argv[4])
meta_path = Path(sys.argv[5])
body = None
meta = None
with log_path.open(encoding="utf-8") as f:
for raw in f:
if not raw.strip():
continue
row = json.loads(raw)
if row.get("event") != "llm_request":
continue
if row.get("request_id") != request_id:
continue
if row.get("model_call_id") != model_call_id:
continue
payload = row.get("payload") or {}
body = payload.get("body")
meta = {
"at": row.get("at"),
"event": row.get("event"),
"conversation_id": row.get("conversation_id"),
"request_id": row.get("request_id"),
"model_call_id": row.get("model_call_id"),
"provider_log": str(log_path),
}
break
if body is None:
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
url="${BASE_URL%/}${ENDPOINT_PATH}"
set +e
curl --no-buffer --silent --show-error \
--connect-timeout 30 \
--max-time "$MAX_TIME" \
--request POST "$url" \
--header "content-type: application/json" \
--header "anthropic-version: 2023-06-01" \
--header "User-Agent: claude-cli/1.0.25" \
--header "x-api-key: $API_KEY" \
--header "Authorization: Bearer $API_KEY" \
--data-binary "@$BODY_FILE" \
--dump-header "$HEADER_FILE" \
--output "$RESP_FILE"
code=$?
set -e
echo "curl_exit_code=$code"
echo "body=$BODY_FILE"
echo "headers=$HEADER_FILE"
echo "response=$RESP_FILE"
echo "meta=$META_FILE"
echo "--- response headers ---"
if [[ -f "$HEADER_FILE" ]]; then
python3 - "$HEADER_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
print(line)
PY
fi
echo "--- response first 120 lines ---"
if [[ -f "$RESP_FILE" ]]; then
python3 - "$RESP_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
print(line)
PY
else
echo "响应文件不存在。"
fi
exit "$code"
-176
View File
@@ -1,176 +0,0 @@
---
name: cursor-debug-log
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
---
# Cursor Debug Log
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
## 作用定位
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
- 用来回答“客户端到底发了什么”。
- 用来回答“后端解码后认为这是什么请求”。
- 用来回答“哪些字段被挂到了当前 active request 上”。
- 用来回答“最终 provider request body 是什么样”。
- 用来回答“RunSSE 实际给客户端发送了什么”。
- 不要把它当成 replay history、prompt 输入或状态事实源。
稳定事实源仍然是:
- `history/<conversationId>/state.json`
- `history/<conversationId>/context.json`
- `history/usage.json`
- `logs/app.log`
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
## 固定路径
- 助手根目录:`~/.cursor-local-assistant-v2`
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
- history 根目录:`~/.cursor-local-assistant-v2/history`
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
- 会话 debug 目录:`history/<conversationId>/debug/`
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
通过配置开启 debug logging:
```yaml
log: true
```
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
## 文件如何生成
debug 层随着请求穿过后端边界逐步落盘:
1. `BidiAppend` 收到客户端上行数据。
- 原始 hex 写入 `bidi.raw.jsonl`。
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`。
2. forwarder 把解码结果转成 active runtime state。
- stream/request 状态决策写入 `runtime.jsonl`。
3. provider pass 被准备并执行。
- adapter 前的请求摘要、`model_call_id`、`provider_pass` 等写入 `provider.jsonl`。
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`。
4. `RunSSE` 把后端输出流式发送给客户端。
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`。
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
## Debug 文件含义
`bidi.raw.jsonl`
- 方向:客户端到后端。
- 包含 `request_id`、可选 `conversation_id`、`append_seqno`、`status`、原始 `data_hex`。
- 当需要精确确认客户端上传字节时先看它。
`bidi.decoded.jsonl`
- 方向:客户端到后端,protobuf 解码后。
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson:`message`。
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message`、`user_message`、`request_context`、`conversation_state`、exec/interaction/kv 回包等。
- 还包含 `message_case`、`requested_model`、`conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message` 和 `intent` 里的完整字段。
`runtime.jsonl`
- 方向:后端内部 runtime。
- 包含状态流转,以及挂到 active stream/request 上的字段。
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
`provider.jsonl`
- 方向:后端到 provider adapter/provider。
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
`runsse.jsonl`
- 方向:后端到客户端。
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
## 查询流程
1. 先判断 id 类型。
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`。
- 再在 `history/*/{state.json,context.json}`、`history/usage.json`、`logs/app.log` 里搜索 request/model-call/tool id。
2. 拿到 `conversationId` 后,列出 debug 目录。
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
- 读取 `config.yaml`。
- 对比 `config.yaml`、`state.json`、`context.json` 的 mtime。
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
4. 按时间顺序读 JSONL,并用这些字段串联:
- `request_id`
- `conversation_id`
- `model_call_id`
- `provider_pass`
- `append_seqno`
- event timestamp
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
常用命令:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
REQ="<requestId>"
CONV="<conversationId>"
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
find "$ROOT/history" -path "*/debug/*" -type f | sort
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
```
紧凑查看 JSONL:
```bash
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
```
## 证据怎么用
根据问题选择对应文件:
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl` 的 `message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
- 后端如何理解请求:看 `bidi.decoded.jsonl` 的 `intent`,再接 `runtime.jsonl`。
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`。
- UI/流式输出问题:看 `runsse.jsonl`。
- 请求状态问题:先看 `state.json`、`context.json`、`usage.json`,再用 debug 文件补证。
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
- `provider.jsonl` 说明最终为 provider 准备了什么。
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
注意证据边界:
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`。
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
## Debug 文件缺失
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
- 请求发生时 `log: false`。
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`。
- 请求在开启 `log` 前已经完成。
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
debug 证据缺失时,回退到 `state.json`、`context.json`、`usage.json`、`logs/app.log`,并把结论标成推断,而不是直接证明。
@@ -0,0 +1,82 @@
---
name: cursor-prefix-stability
description: Implement and review Cursor BYOK conversation projection with append-only provider history and prefix-cache stability. Use when changing runtime prompts, request context, canonical messages, checkpoint hydration, compaction, message identity, or provider history serialization under server.
---
# Cursor prefix stability
Preserve the longest valid provider prefix across turns. Treat provider-visible history as an append-only log unless an explicit compaction operation replaces it.
## Architecture
Keep the invariant in the provider-independent conversation layers:
```text
server/
├── prompt/cursor/*/runtime.md Per-turn runtime content
├── src/cursor/request/ Request context and runtime compilation
├── src/cursor/projection/ Canonical ↔ Cursor checkpoint projection
├── src/cursor/checkpoint/ Stable roots, turns, and hydration
├── src/run/ Provider-independent model history
└── src/provider/ Provider-specific serialization only
```
Do not solve prefix instability independently in each provider adapter. Produce one stable canonical history before dispatching to OpenAI Responses, OpenAI Chat, Anthropic, or another provider.
## Required invariants
- When no compaction occurs, the complete provider-visible history from turn N must be an exact structural prefix of turn N+1. Never edit, remove, merge, reorder, renormalize, or regenerate an earlier message.
- Keep `PromptSpec.instructions` and the stable tool prefix byte-stable when their inputs have not changed. Deterministic ordering is required; do not use unordered iteration in provider-visible output.
- Separate conversation/request context from the per-turn runtime message. The runtime message contains current-turn material such as the user query, selected context, open files, action context, mode reminders, and timestamp.
- Project rules, skills, subagents, environment/Git context, and MCP metadata as a stable `request-context:*` message:
- append it on the first applicable turn;
- do not append it again when its compiled content is identical to the latest projected request context;
- when its content changes, append a new request-context message immediately before the current runtime message;
- never represent a context change by rewriting the system prompt, replacing an earlier context message, or mutating a checkpoint root.
- Give every appended context update a unique event identity. Compare the latest context by content, not only by identifier, so `A → B → A` appends the final `A` again while retries of the same event remain idempotent.
- Preserve `request-context:*` wire identity through checkpoint encoding and hydration. Deduplication must still work after process restart or conversation resume.
- Automatic compaction is an explicit prefix reset. Compact obsolete history, retain exactly the latest request-context message, then place the summary and current initial messages in deterministic order. Manual compaction may reproject current context on the next user turn.
- Background completions and injected runtime events must not manufacture duplicate request context unless they actually start a user turn whose context changed.
## Change workflow
Before editing, trace the whole path that applies:
```text
AgentRunRequest
→ request context hydration/compilation
→ CanonicalMessage identity and persistence
→ checkpoint encode/decode
→ projected ModelRequest history
→ provider serialization
```
Determine which data is conversation-level and which is turn-level. If a proposed change moves or rewrites an earlier provider-visible value, redesign it as a new append-only event unless the operation is explicitly compaction.
Use TDD for changes in this path. Start with a failing behavioral test, then implement the smallest provider-independent change.
## Verification
Cover the affected behavior with structural assertions, not token-count estimates alone:
- Two turns with identical request context: the first request history is an exact prefix of the second, the system instructions are identical, and only one `request-context:*` message exists.
- Changed context: one new context message appears at the tail before the new runtime query; all earlier messages remain unchanged.
- Context reversion `A → B → A`: three distinct context events are retained in order.
- Retry of one runtime event: no duplicate or conflicting context message is persisted.
- Checkpoint round-trip: request-context identity and content survive encode/hydrate.
- Automatic compaction: only the latest context is retained outside the summary.
- Runtime templates render without embedding conversation-level rules or MCP metadata in every user query.
Run focused tests first, then the relevant server suites:
```bash
cargo test --lib
cargo test --test runtime_modes
cargo test --test prefix_stability
cargo test --test checkpoint_recovery
cargo test --test compaction
cargo clippy --lib -- -D warnings
cargo fmt --all -- --check
```
Do not repair unrelated dirty-worktree failures while validating. Report them separately.
+84
View File
@@ -0,0 +1,84 @@
---
name: database-schema
description: Implement and review Cursor BYOK SQLite schema changes. Use when adding or changing tables, columns, indexes, constraints, foreign keys, SQLx migrations, persistence mappings, or database fixtures under server.
---
# Cursor BYOK Database Schema
Treat a schema change as an end-to-end persistence change, not as an isolated SQL edit. Keep the database, Rust store, API contracts, fixtures, and tests aligned.
## Architecture
Use the existing layers and keep responsibilities in their current directories:
```text
server/
├── migrations/ Ordered SQLite/SQLx migrations
├── src/store/ Queries, bindings, row decoding, transactions
├── src/ Domain and API types that consume persisted data
└── tests/ Migration, store, API, and integration coverage
```
Before editing, inspect the complete table definition, every query that reads or writes it, its Rust types, API projections, and relevant fixtures. Search by the table name and affected field names; do not infer the persistence path from one file.
## Migration rules
- `server/src/store/sqlite.rs` runs embedded SQLx migrations from `server/migrations`.
- Never modify, rename, reorder, or delete a migration that may already have been applied. SQLx records its checksum; changing an applied file causes startup failure with `migration ... was previously applied but has been modified`.
- Add the next numbered forward migration, using a descriptive filename such as `0003_add_request_protocol.sql`.
- A fresh database must reach the current schema by applying all migrations in order. Do not duplicate a new column or table in both the initial migration and a later migration.
- Only squash or rewrite migration history when the user explicitly asks for a full database reset and accepts that existing databases will no longer start. Do not infer that permission from a development-only workflow.
- Do not add compatibility views, triggers, shadow fields, or fallback reads. Migrate once, then make the application consume the new schema directly.
- Keep one coherent schema change together. Split unrelated changes into separate migrations.
## SQLite design
- Choose nullability from domain meaning. Use `NULL` for genuinely unknown historical data; use a default only when it is correct for every existing row.
- Store booleans as constrained integers, for example `INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1))`.
- Add `CHECK`, `UNIQUE`, and foreign-key constraints when they express real invariants. Choose `ON DELETE` behavior deliberately.
- Add an index only for a demonstrated lookup, join, ordering, or uniqueness requirement. Match its leading columns to actual query shapes.
- Use a transaction for changes that must update multiple tables atomically.
- SQLite supports only limited `ALTER TABLE`. For an unsupported constraint, type, or destructive column change, create the replacement table with the final schema, copy and transform data, replace the old table, and recreate required indexes and foreign keys in one migration.
- Preserve timestamps, identifiers, and existing semantic values during table rebuilds. Do not silently manufacture domain data.
## Application changes
Trace every changed field through the full path that applies:
1. Migration SQL and constraints.
2. Rust domain/request/response structs.
3. SQL column lists, placeholders, `.bind(...)` order, row decoding, and update statements.
4. Transactions and repository/store methods.
5. API serialization and frontend TypeScript types when the field is exposed.
6. UI creation, editing, listing, and details when requested by the product behavior.
7. Test fixtures, literal struct initializers, snapshots, and mock rows.
List SQL columns explicitly. Keep selected-column order, row decoding, insert columns, and bind order visibly aligned. Avoid `SELECT *` because schema additions can silently invalidate positional decoding assumptions.
When a value records the effective behavior of a call, persist the value actually consumed at execution time rather than merely the model or provider default. Keep absent, defaulted, and explicitly supplied values distinguishable when that distinction matters.
## Verification
Add focused coverage proportional to the change:
- A fresh database applies every migration.
- A database at the previous migration upgrades successfully and preserves existing rows.
- Store create/read/update paths round-trip the new fields.
- Defaults, nullability, uniqueness, checks, and foreign keys behave as designed.
- Multi-table writes roll back atomically on failure.
- API and frontend types expose the same semantics when applicable.
Run the narrow tests first, then the repository checks affected by the change. At minimum for server schema work, run:
```bash
cargo fmt --all -- --check
cargo test --workspace
```
If frontend contracts changed, also run from `apps/desktop`:
```bash
npm run check
```
Do not repair unrelated dirty-worktree changes while validating. Report any pre-existing failure separately from failures caused by the schema change.
+24
View File
@@ -0,0 +1,24 @@
---
name: floating-ui
description: Implement or review floating desktop UI such as dropdowns, popovers, tooltips, menus, comboboxes, and nested overlays under apps/desktop.
---
# Floating UI
Use `@floating-ui/dom` for every interactive element positioned relative to a trigger. Do not calculate coordinates manually or rely on CSS absolute offsets for dropdowns, popovers, menus, combobox lists, or tooltips.
## Required structure
- Render overlays through `createPortal(..., document.body)` so layout and stacking contexts do not clip them.
- Position with `computePosition` inside `autoUpdate`; clean up the function returned by `autoUpdate` when the overlay closes or unmounts.
- Start from the closest existing control in `apps/desktop/src/components/ui`. Match its `placement`, `offset`, `flip`, `shift`, and `size` middleware unless the interaction requires a deliberate difference.
- Store computed `left`, `top`, width, and available height in React state. Apply the state to the portal root; do not mutate element styles directly.
- Use `size` when reference width or viewport height constrains the overlay. Lists that can grow must use `components/virtual/VirtualList.tsx`; keep fixed headers and footers outside the virtual viewport.
## Interaction invariants
- Keep the trigger's open/focus border visible while focus is inside a portaled overlay.
- Close on outside pointer interaction and Escape, then return focus to the trigger.
- Outside-click checks must include both trigger and overlay. A nested portaled overlay must stop its pointer event from reaching the parent's outside-click listener, so interacting with a child menu never closes its parent.
- Expose trigger state with `aria-expanded`, `aria-controls`, and the appropriate `aria-haspopup`; give the overlay the matching menu, listbox, dialog, or tooltip semantics.
- Verify placement near all viewport edges, nested-overlay clicks, keyboard dismissal, virtual scrolling, and fixed footer behavior.
+90
View File
@@ -0,0 +1,90 @@
---
name: frontend
description: Implement and review the Cursor BYOK React/Tauri desktop frontend. Use for changes under apps/desktop involving routing, page layout, window chrome, settings UI, scrolling, virtualization, charts, themes, or frontend component architecture.
---
# Cursor BYOK Frontend
Build on the components and theme system already present in `apps/desktop`. Preserve the Tauri HTTP boundary; frontend management features call `/__byok-api__/api` and never add IPC business APIs.
## Desktop HTTP communication
- Treat the Rust management server as the single HTTP origin for the desktop UI. The main WebView and browser-opened detail pages must use `http://127.0.0.1:<dynamic-port>/__byok-api__/`; do not load the main UI from `tauri://localhost` or expose a second frontend origin.
- Make every frontend management request relative to `/__byok-api__/api`. Do not discover, inject, persist, or pass an `apiOrigin`, and do not construct management URLs from a fixed port.
- Keep the `/__byok-api__/` namespace reserved for this boundary:
- `/__byok-api__/api/*` is handled locally by the Rust management API.
- Other `/__byok-api__/*` paths are frontend documents, assets, modules, and development resources.
- In production, serve the frontend embedded by Tauri's `frontendDist` through the Rust server. Reuse Tauri's asset resolver rather than bundling or copying a second set of frontend resources.
- In development, let the Rust server reverse-proxy non-API `/__byok-api__/*` requests to Vite. Preserve the request path and query string. Do not configure Vite to proxy management API requests back to Rust.
- Configure Vite's base path as `/__byok-api__/` so generated assets, module imports, and development client URLs stay under the reserved namespace.
- Bind the development Vite server to an explicit loopback address compatible with the Rust proxy target; do not rely on `localhost` resolving to the same IP family.
- Open external detail pages with their normal loopback HTTP URL and hash route. Because the page and API are same-origin, do not add Tauri URL workarounds, API-origin query parameters, or CORS-dependent browser flows.
- Use Tauri commands only for native desktop capabilities such as opening Terminal, tray integration, or operating-system actions. Do not create IPC mirrors for HTTP business APIs.
## Component boundaries
- Keep Provider, Model, Call, Usage, and persisted settings state outside presentation components.
- Let components receive business snapshots and dispatch actions. Local React state is allowed for transient UI behavior such as focus, open state, drag state, and input drafts.
- Split pages, layouts, charts, forms, and reusable controls into focused components. Do not collect an entire feature set in `App.tsx`.
- Keep `App.tsx` as the route composition root.
## Page layout
- Build every full-height page with `components/layout/PageLayout.tsx`; do not hand-roll page shells with grid rows.
- Put page-level commands and controls such as create, import, export, filtering, sorting, and view options in the shared page action region through `PageActions`. Keep only operations that target one concrete list item, such as edit and delete, inside that item's row. Do not add duplicate action bars or panel-header controls inside page content.
- Never render a title region above a data table or flat data list. Use the page title for context, keep column labels in the table header, place page-level controls in `PageActions`, and wrap the table in a titleless `Card`. A hierarchy label that identifies parent data in a grouped parent-child view is not a table title.
- Keep the main scroll viewport full-height and `position: relative`; place the page title and action region absolutely over it.
- Reserve the absolute title/action region with `padding-top` on the scroll content, never by shortening or offsetting the scroll viewport itself.
- Keep the menu Card fixed and reserve it with the content area's `margin-left`; do not place Header, menu, and content in a flow grid.
- Do not use child or section padding to create external spacing. Use flex/grid gaps between siblings and margins or fixed offsets at container boundaries; the scroll-content top padding is reserved only for its absolute title/action overlay.
- Use a vertical flex layout with these invariants:
- Header: `flex: 0 0 auto`.
- Footer: `flex: 0 0 auto`.
- Content: `flex: 1 1 auto`, `min-height: 0`, and `overflow: hidden`.
- Keep Header and Footer outside the scroll viewport. They must never grow or shrink with content.
- Apply macOS, Windows, and Linux window-corner safe-area variables only to edge chrome that can enter native rounded corners.
- Keep macOS traffic-light space and the top fixed drag strip free of interactive controls.
- Avoid decorative brand text and explanatory copy when it does not help the user perform an action or interpret state.
## Typography
- Write component styles in SCSS and source every `font-size` from `src/styles/_typography.scss`; never hardcode a font size in SCSS, React styles, or chart options.
- Use `base` for normal UI text and ordinary titles, including title bars, panels, cards, and section headings. Do not increase a font merely because its element is `h1` or `h2`.
- Use `xs` only for secondary information. Reserve `lg` for an explicitly oversized display title; do not use it for routine headings.
- Audit all typography-token usages after changing the scale, not only raw numeric font sizes.
## Scrolling and virtualization
- Use `components/layout/VirtualPage.tsx` for vertically scrolling page content.
- Use `components/virtual/VirtualList.tsx` for data collections and menus that can grow.
- Treat `ScrollArea` as the low-level primitive owned by the virtual scrolling implementation. Do not import `ScrollArea` directly in pages or layouts.
- Use the global `scroll-shadow-top` and `scroll-shadow-bottom` masks for scroll-edge shadows. When a scroll content inset reserves an absolute overlay, source the top shadow distance from the same CSS variable as its `padding-top`.
- Model non-list pages as a short sequence of measurable top-level virtual sections.
- Do not use document scrolling, native page `overflow: auto`, or nested vertical scroll containers.
- Ensure every flex/grid ancestor of a virtual viewport has `min-height: 0` and the viewport has an explicit bounded height.
## Routing and pages
- Use `HashRouter` for Tauri compatibility.
- Keep `/` as the statistics dashboard and expose every primary page through the persistent menu in the shared `AppLayout`.
- Use top-level routes for primary pages: `/`, `/providers`, `/models`, `/calls`, and `/settings`.
- Do not create separate Home and Settings shells or add a Home-switching control.
- Keep route content independent of the window Header and Footer.
## Charts
- Use ECharts through `components/charts/EChart.tsx`.
- Register only required ECharts Core charts, components, and renderers.
- Keep chart components declarative: accept domain data and construct an option without fetching or persisting data.
- Let `ResizeObserver` resize the chart with its layout container and dispose the instance on unmount.
## Validation
Run from `apps/desktop`:
```bash
npm run check
npm run tauri:build -- --debug --no-bundle
```
Verify that Header and Footer remain fixed, only the virtual content viewport scrolls, settings navigation remains reachable at minimum window size, and macOS controls stay outside rounded-corner and traffic-light unsafe regions.
@@ -0,0 +1,4 @@
interface:
display_name: "Cursor BYOK Frontend"
short_description: "Build consistent React desktop layouts and virtualized pages"
default_prompt: "Use $frontend to implement or review the Cursor BYOK React desktop interface."
-42
View File
@@ -1,42 +0,0 @@
---
name: i18n-requirements
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
---
# I18n Requirements
## Source Messages
- Treat `zh-CN` as the only source locale.
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
## Generated Catalogs
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
## Adding A Locale
Update all of these integration points together:
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
- Native tray labels in `internal/app/runner.go`.
## Verification
After the scan build:
1. Confirm `npm run build` succeeds.
2. Confirm every locale JSON has the same keys as `catalog.json`.
3. Confirm non-source locale files contain no empty values.
4. Confirm translated placeholders match the source entry placeholders.
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
@@ -1,4 +0,0 @@
interface:
display_name: "I18n Requirements"
short_description: "Keep UI translations and generated catalogs in sync"
default_prompt: "Use $i18n-requirements to update localized UI text safely."
+48
View File
@@ -0,0 +1,48 @@
---
name: i18n
description: Implement and review Cursor BYOK desktop localization, including t() usage, locale catalogs, system-language selection, translation completeness, and language settings under apps/desktop.
---
# Desktop i18n
Keep localization organized as:
```text
apps/desktop/
├── plugins/static-i18n-plugin.ts
└── src/i18n/
├── I18nRoot.tsx
├── runtime.ts
├── store.ts
├── generated/catalog.json
└── locales/
├── zh-CN.json
└── en-US.json
```
## Author messages
- Use the global `t()` function with a static Simplified Chinese string literal: `t("保存")`.
- Do not import or locally declare `t`.
- Keep calls inside render paths when the result must update after a language change. Do not translate module-level UI constants.
- Use named placeholders with a static object: `t("第 {page} 页", { page })`.
- Preserve every placeholder name exactly in all translations.
- Wrap all user-visible labels, descriptions, tooltips, empty states, validation messages, and accessibility labels. Do not wrap protocol tokens, model IDs, URLs, or product names that should remain unchanged.
## Locale behavior
- `system` is the default preference.
- Resolve supported operating-system languages to their locale and fall back to `en-US` for every unsupported language.
- Persist only explicit locale selections. Removing the preference restores system-language behavior.
- Apply changes immediately and update the document `lang` attribute.
## Update catalogs
From `apps/desktop`:
1. Run `npm run i18n:scan` after adding or changing `t()` sources.
2. Translate every empty entry in `src/i18n/locales/en-US.json`.
3. Never edit `src/i18n/generated/catalog.json` or `zh-CN.json` manually; scanning owns them.
4. Run `npm run check`. A normal build must fail on a missing translation or mismatched placeholder.
When adding another locale, add it to the plugin's supported locales, runtime locale type and messages, locale resolver, settings options, and provide a complete locale JSON file.
@@ -1,35 +0,0 @@
---
name: prefix-cache-stability
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
---
# Prefix Cache Stability
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
## Hard Constraints
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
- Do not move previously sent model-visible messages to a new position in later requests.
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
- Never optimize cache by dropping correctness-critical context.
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
## Implementation Pattern
1. Classify each prompt addition:
- Stable system policy: belongs in the fixed system prompt.
- Historical model-visible context: persist as replayable history.
- Latest-only attention: append as current suffix, do not persist.
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
## Verification
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
+77
View File
@@ -0,0 +1,77 @@
---
name: release
description: Prepare, authorize, publish, troubleshoot, and verify Cursor BYOK desktop GitHub Releases. Use for version bumps, release tags, GitHub Actions release runs, updater manifests, signing, or release-readiness checks.
---
# Desktop release
Release through `.github/workflows/release.yml`. Preserve both updater formats: Tauri uses `latest.json`; legacy `v0.0.49` clients use `update.json`.
## Publication authority
- Only the repository author, GitHub user `leookun`, may authorize a live release.
- Before any live mutation, require an explicit release instruction from the author in the current task and verify `gh api user --jq .login` returns `leookun`.
- Treat all of these as publication actions: pushing a `v*` tag, rerunning the release workflow, and publishing or editing a GitHub Release. Pushing a release commit to `main` only prepares the release and must never trigger publication by itself.
- Without that authorization, restrict work to inspection, local edits, validation, and a release-ready commit or branch. Do not infer publication permission from requests such as “prepare”, “check”, or “ready to release”.
- Never print, commit, or upload `.tauri/cursor-byok.key` anywhere except the repository's `TAURI_SIGNING_PRIVATE_KEY` Actions Secret when the author explicitly requests that secret configuration.
- Never delete, replace, or move an existing tag or published Release without separate explicit authorization.
## Version and GitHub Release policy
- Do not use GitHub prereleases. Keep `prerelease: false` for every release and publish the completed release as Latest.
- Use `vMAJOR.MINOR.PATCH` for a stable tag, for example `v0.1.0`.
- Use standard SemVer `vMAJOR.MINOR.PATCH-beta.N` for a test tag, for example `v0.1.0-beta.1`. A beta is still a normal GitHub Release, not a GitHub prerelease. Make its title or body visibly say Beta.
- This normal-Release rule is required because both installed update clients resolve assets through GitHub's `/releases/latest/download/` path, which excludes GitHub prereleases.
- Windows beta builds must use the NSIS bundle. WiX/MSI rejects nonnumeric prerelease identifiers such as `beta.1`; do not weaken the SemVer tag to accommodate MSI.
- Release only from a `v*` tag whose commit is contained in `origin/main`. The tag must equal `v<version>` from the desktop manifests.
- Keep ordinary `main` pushes and manual workflow dispatch disabled as release triggers. The author pushes the matching tag only after the release commit is present on `origin/main`.
- Never republish an already published version. Select a new version instead.
## Release sources
Keep the desktop version identical in the manifests and their locks:
```text
cursor-byok/
├── Cargo.lock
├── apps/desktop/
│ ├── package.json
│ ├── package-lock.json
│ └── src-tauri/
│ ├── Cargo.toml
│ └── tauri.conf.json
├── scripts/cursor-proto/proto/
│ ├── agent_v1.proto
│ └── aiserver_v1.proto
└── .github/workflows/release.yml
```
The two listed Proto files are required build inputs and must be committed. Keep the other locally extracted Proto files ignored unless the build starts depending on them.
## Prepare and validate
1. Inspect `git status`, fetch `origin/main`, and preserve unrelated user changes. Confirm the release commit is based on the current remote head.
2. Choose stable or beta numbering explicitly. Update the desktop version in both manifests and lockfiles; do not change the independent `cursor-server` version merely to release the desktop app.
3. Confirm the updater public key in `tauri.conf.json` matches `.tauri/cursor-byok.key.pub` without exposing the private key.
4. Confirm `TAURI_SIGNING_PRIVATE_KEY` exists in GitHub Actions. `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` must be absent when the local key has no password.
5. Confirm neither the intended tag nor Release already exists.
6. From `apps/desktop`, run:
```bash
npm run check
npm run tauri:build -- --debug --no-bundle
```
7. Validate the workflow YAML and inspect the staged diff. Ensure `.tauri/`, unrelated local files, and unrelated user changes are not staged.
8. Use the `tauri-action@v1` input `uploadUpdaterJson: true`; `includeUpdaterJson` is not a valid v1 input.
## Publish and verify
After the author explicitly authorizes publication:
1. Commit only the reviewed release set and push it to `main`. Confirm the release commit is present in `origin/main`; this push must not start the release workflow.
2. Create the matching tag on that commit, for example `v0.1.0-beta.1`, and push only that tag. This tag push is the publication trigger.
3. Follow the triggered `Release desktop app` run through completion. Report the run URL and stop on failure; diagnose locally before asking the author to authorize another live attempt.
4. Verify `v<version>` exists, is published rather than draft, has `prerelease: false`, and is the repository's Latest release.
5. Verify the Release contains signed Tauri updater artifacts plus `latest.json`, and the legacy platform archives plus `update.json`.
6. For a beta, report clearly that it is a test version even though GitHub represents it as a normal Latest Release.
@@ -0,0 +1,4 @@
interface:
display_name: "Desktop Release"
short_description: "Prepare, authorize, publish, and verify desktop releases"
default_prompt: "Use $release to prepare and verify a Cursor BYOK desktop release."
+13 -9
View File
@@ -1,9 +1,13 @@
*
!go.mod
!go.sum
!cursor-tab-server/
!cursor-tab-server/**
!gen/
!gen/**
!internal/
!internal/**
.git
.DS_Store
**/.DS_Store
**/node_modules
**/target
**/dist
server/*.db
server/*.db-shm
server/*.db-wal
docs
logs
*.tar
*.zip
-12
View File
@@ -1,12 +0,0 @@
## 变更说明 / What
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
## 关联 Issue / Related Issue
<!-- 例如 / e.g. Closes #161 -->
## 测试方式 / How to Test
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
+226
View File
@@ -0,0 +1,226 @@
name: Release desktop app
on:
push:
tags:
- "v*"
permissions:
contents: write
concurrency:
group: desktop-release
cancel-in-progress: false
jobs:
prepare:
name: Prepare release
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.release.outputs.should_publish }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify release author
env:
REPOSITORY_OWNER: ${{ github.repository_owner }}
shell: bash
run: |
if [[ "${GITHUB_ACTOR}" != "${REPOSITORY_OWNER}" ]]; then
echo "Only ${REPOSITORY_OWNER} may publish a release" >&2
exit 1
fi
- name: Verify updater signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
shell: bash
run: |
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY}" ]]; then
echo "TAURI_SIGNING_PRIVATE_KEY is not configured" >&2
exit 1
fi
- name: Read and verify app version
id: version
shell: bash
run: |
version=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")
package_version=$(node -p "require('./apps/desktop/package.json').version")
cargo_version=$(sed -n '/^version = / { s/version = "\([^"]*\)"/\1/p; q; }' apps/desktop/src-tauri/Cargo.toml)
test "${version}" = "${package_version}"
test "${version}" = "${cargo_version}"
test "${GITHUB_REF_TYPE}" = "tag"
test "${GITHUB_REF_NAME}" = "v${version}"
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
echo "Release tag must point to a commit contained in origin/main" >&2
exit 1
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Check whether this version is already published
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
shell: bash
run: |
state=$(gh release view "v${VERSION}" --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || true)
if [[ "${state}" = "published" ]]; then
echo "Version ${VERSION} is already published; nothing to do."
echo "should_publish=false" >> "${GITHUB_OUTPUT}"
else
echo "should_publish=true" >> "${GITHUB_OUTPUT}"
fi
publish:
name: Publish (${{ matrix.platform }})
needs: prepare
if: needs.prepare.outputs.should_publish == 'true'
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x86_64
os: ubuntu-22.04
args: ""
target: ""
- platform: windows-x86_64
os: windows-latest
args: "--bundles nsis"
target: ""
- platform: macos-aarch64
os: macos-15
args: "--target aarch64-apple-darwin"
target: aarch64-apple-darwin
- platform: macos-x86_64
os: macos-15-intel
args: "--target x86_64-apple-darwin"
target: x86_64-apple-darwin
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies
if: matrix.platform == 'linux-x86_64'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- uses: dtolnay/rust-toolchain@stable
- name: Install Rust target
if: matrix.target != ''
run: rustup target add ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/desktop/package-lock.json
- name: Install frontend dependencies
working-directory: apps/desktop
run: npm ci
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ contains(matrix.platform, 'macos') && '-' || '' }}
with:
projectPath: apps/desktop
tagName: v__VERSION__
releaseName: Cursor BYOK v__VERSION__
releaseBody: ${{ contains(needs.prepare.outputs.version, '-') && 'Beta release. Download the installer for your platform from the assets below.' || 'Download the installer for your platform from the assets below.' }}
releaseDraft: true
prerelease: false
uploadUpdaterJson: true
args: ${{ matrix.args }}
- name: Package legacy Linux updater asset
if: matrix.platform == 'linux-x86_64'
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p legacy-update
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
- name: Package legacy Windows updater asset
if: matrix.platform == 'windows-x86_64'
shell: pwsh
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
New-Item -ItemType Directory -Force legacy-update | Out-Null
Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip"
- name: Package legacy macOS updater asset
if: contains(matrix.platform, 'macos')
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir -p legacy-update
archive=$(find "target/${{ matrix.target }}/release/bundle/macos" -maxdepth 1 -name '*.app.tar.gz' -print -quit)
test -n "${archive}"
legacy_platform=macos-arm64
if [[ "${{ matrix.platform }}" = "macos-x86_64" ]]; then
legacy_platform=macos-amd64
fi
cp "${archive}" "legacy-update/cursor-byok-${VERSION}-${legacy_platform}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: legacy-update-${{ matrix.platform }}
path: legacy-update/*
if-no-files-found: error
finalize:
name: Publish GitHub Release
needs: [prepare, publish]
if: needs.prepare.outputs.should_publish == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: legacy-update-*
path: legacy-update
merge-multiple: true
- name: Generate legacy update manifest
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node scripts/release/generate-legacy-update.mjs \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--assets-dir legacy-update \
--output legacy-update/update.json \
--notes "Cursor BYOK v${VERSION}"
- name: Upload legacy updater assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release upload "v${VERSION}" legacy-update/* --clobber
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: gh release edit "v${VERSION}" --draft=false --latest
+16 -8
View File
@@ -1,14 +1,27 @@
claude-server.tar
.DS_Store
dist
.task
/local-docs/
bin
logs/
cursor-server.db
cursor-server.db-shm
cursor-server.db-wal
gen/
logs.zip
frontend/bindings
dist
node_modules
*.tsbuildinfo
cursor-server.tar
/target/
/server/target/
/apps/desktop/src-tauri/gen/
/.tauri/
/server/*.db
/server/*.db-shm
/server/*.db-wal
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
@@ -16,12 +29,7 @@ server-go/log/
.cursor-local-assistant-v2
.cursor-app-formatted/
proto/extensions-cursor-app/
ads-server-linux-amd64.tar
cmd/ads-server/*.db
cmd/ads-server/*.db-*
cmd/ads-server/*.sqlite
cmd/ads-server/*.sqlite-*
cmd/ads-server/data/
cmd/ads-server/ads-server
cmd/ads-server/ads-server-linux-amd64.tar
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/scripts/cursor-proto/proto/*
!/scripts/cursor-proto/proto/agent_v1.proto
!/scripts/cursor-proto/proto/aiserver_v1.proto
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
- **The directory structure is the architecture.** Simple, clear directory and module naming >= module dependency relationships > concrete implementation details; communicate with the user using directory trees.
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
-90
View File
@@ -1,90 +0,0 @@
# 贡献指南
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
感谢你考虑为 cursor-byok 做出贡献!
## 开发环境
| 依赖 | 版本要求 |
|------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Linux 额外依赖:`libgtk-3-dev`、`libwebkit2gtk-4.1-dev`(Wails 运行时需要)。
## 快速开始
```bash
# 安装前端依赖
cd frontend && yarn install --frozen-lockfile && cd ..
# 启动开发模式(热重载)
task dev
# 构建当前平台分发包
task build
```
## 项目结构
```
├── main.go # 入口
├── internal/ # Go 后端(代理、转发、客户端管理等)
├── frontend/ # Vue 3 + Vite + Tailwind 前端
│ ├── src/
│ │ ├── views/ # 页面
│ │ ├── components/ # 组件
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU)
│ │ └── state/ # 全局状态
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
├── prompt/ # 内置 Agent prompt 模板
├── proto/ # Protobuf 定义
├── build/ # 构建配置与平台 Taskfile
├── scripts/ # 辅助脚本(release、metrics)
└── Taskfile.yml # 顶层任务编排
```
## 开发规范
### 提交信息
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
```
feat(proxy): 支持自定义 upstream 超时
fix(i18n): 补全日语翻译缺失 key
release: 0.0.42
```
### 代码风格
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
- 前端:Vue SFC + Composition API,Tailwind 工具类优先。
- 新增 UI 文案必须同步更新所有 locale 文件(`frontend/src/i18n/locales/`)。
### 分支与 PR
1. 从 `main` 创建功能分支:`feat/xxx`、`fix/xxx`。
2. 保持 PR 小而聚焦,一个 PR 解决一个问题。
3. PR 描述中说明动机和测试方式。
## 构建与发布
```bash
# 构建全平台(仅 macOS 主机)
task build:all
# 准备发布资产
task release:prepare
# 发布到 GitHub Releases
task release:github
```
## 许可证
提交代码即表示你同意以 [MIT License](./LICENSE) 授权你的贡献。
-90
View File
@@ -1,90 +0,0 @@
# Contributing Guide
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
Thank you for considering contributing to cursor-byok!
## Prerequisites
| Dependency | Version |
|------------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
## Quick Start
```bash
# Install frontend dependencies
cd frontend && yarn install --frozen-lockfile && cd ..
# Start dev mode (hot reload)
task dev
# Build for current platform
task build
```
## Project Structure
```
├── main.go # Entry point
├── internal/ # Go backend (proxy, forwarding, client management)
├── frontend/ # Vue 3 + Vite + Tailwind frontend
│ ├── src/
│ │ ├── views/ # Pages
│ │ ├── components/ # Components
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
│ │ └── state/ # Global state
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
├── prompt/ # Built-in agent prompt templates
├── proto/ # Protobuf definitions
├── build/ # Build configs & platform Taskfiles
├── scripts/ # Helper scripts (release, metrics)
└── Taskfile.yml # Top-level task orchestration
```
## Development Guidelines
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(proxy): support custom upstream timeout
fix(i18n): add missing Japanese translation keys
release: 0.0.42
```
### Code Style
- Go: follow `gofmt` / `go vet`; no additional linter config.
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
### Branching & PRs
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
2. Keep PRs small and focused — one problem per PR.
3. Describe motivation and how to test in the PR description.
## Build & Release
```bash
# Build all platforms (macOS host only)
task build:all
# Prepare release assets
task release:prepare
# Publish to GitHub Releases
task release:github
```
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
Generated
+8943
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[workspace]
members = [
"server",
"apps/desktop/src-tauri",
"crates/semble-core",
"benchmarks/semble",
]
resolver = "2"
# Keep local Rust builds compact. Full dependency debug info and incremental
# object caches dominate target/ in this workspace, while line tables for our
# own crates are enough for normal backtraces and source-level debugging.
[profile.dev]
debug = 1
incremental = false
[profile.dev.package."*"]
debug = 0
[profile.test]
debug = 1
incremental = false
[profile.test.package."*"]
debug = 0
+51
View File
@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS web
WORKDIR /src/apps/desktop
COPY apps/desktop/package.json apps/desktop/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY apps/desktop/index.html apps/desktop/tsconfig.json apps/desktop/tsconfig.node.json apps/desktop/vite.config.ts ./
COPY apps/desktop/plugins/ plugins/
COPY apps/desktop/public/ public/
COPY apps/desktop/src/ src/
RUN npm run build
FROM rust:1-bookworm AS server
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY server/ server/
COPY apps/desktop/src-tauri/Cargo.toml apps/desktop/src-tauri/Cargo.toml
COPY apps/desktop/src-tauri/build.rs apps/desktop/src-tauri/build.rs
COPY apps/desktop/src-tauri/src/ apps/desktop/src-tauri/src/
COPY apps/desktop/src-tauri/capabilities/ apps/desktop/src-tauri/capabilities/
COPY apps/desktop/src-tauri/icons/ apps/desktop/src-tauri/icons/
COPY apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/tauri.conf.json
COPY scripts/cursor-proto/proto/ scripts/cursor-proto/proto/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
cargo build --release --locked --package cursor-server --bin cursor-server && \
cp target/release/cursor-server /tmp/cursor-server
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install --yes --no-install-recommends ca-certificates curl && \
rm -rf /var/lib/apt/lists/* && \
useradd --system --uid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin cursor-byok && \
mkdir -p /app/console /data && \
chown cursor-byok:cursor-byok /data
COPY --from=server /tmp/cursor-server /usr/local/bin/cursor-server
COPY --from=web /src/apps/desktop/dist/ /app/console/
ENV CURSOR_LISTEN_ADDR=0.0.0.0:3000 \
CURSOR_DATABASE_URL=sqlite:///data/cursor-server.db \
CURSOR_CONSOLE_DIR=/app/console \
RUST_LOG=cursor_server=info
USER cursor-byok
EXPOSE 3000
VOLUME ["/data"]
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD curl --fail --silent http://127.0.0.1:3000/__byok-api__/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/cursor-server"]
+28
View File
@@ -0,0 +1,28 @@
.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker
check:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
npm --prefix apps/desktop run check
dev-web:
npm --prefix apps/desktop run dev:web
dev-server:
CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server
dev-desktop:
npm --prefix apps/desktop run tauri:dev
build-web:
npm --prefix apps/desktop run build
build-server:
cargo build --release --package cursor-server --bin cursor-server
build-desktop:
npm --prefix apps/desktop run tauri:build
build-docker:
docker build --tag cursor-byok:local .
+104 -33
View File
@@ -1,39 +1,110 @@
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
<div align="center">
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
# cursor-byok
cursor-byok is a local implementation of Cursor's backend.
<br>
<br>
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
## 交流群组
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 可以自由接入到你想使用的任何工具中。
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
![cursor-byok dashboard](./images/en-home.png)
## About
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT]
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## Features
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **Cross-platform:** Run on macOS, Windows, and Linux.
## Quick Start
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Open Cursor, select the configured model, and start using Agent.
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model.png)
## How It Works
```text
Cursor client
│
│ Agent requests and tool results
▼
cursor-byok local service
│
│ OpenAI- / Anthropic-compatible requests
▼
Your model API
```
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
## Why This Project
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## Roadmap
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
## Community and Support
- [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## Contributors
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
</a>
## License
This project is open source under the [MIT License](./LICENSE).
-569
View File
@@ -1,569 +0,0 @@
version: "3"
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
vars:
APP_NAME: "Cursor助手"
BIN_DIR: "bin"
APP_VERSION:
sh: 'go run ./scripts/release version -config ./build/config.yml'
RELEASE_REPO: "leookun/cursor-byok"
RELEASE_BASE_NAME: "cursor-byok"
RELEASE_SOURCE_PATH: "release-notes.md"
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
SCAN: '{{.SCAN | default "false"}}'
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
CURRENT_LINUX_ARCH: "amd64"
CURRENT_LINUX_NAME: "linux-amd64"
tasks:
build:
summary: 构建当前系统分发包
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
cmds:
- task: clean:dist
- task: build:current
run:
summary: 运行当前系统版本
cmds:
- task: "{{OS}}:run"
build:all:
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
cmds:
- task: clean:dist
- task: build:darwin:arm64
- task: build:darwin:amd64
- task: build:windows:386
- task: build:windows:amd64
build:current:
internal: true
cmds:
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
build:darwin:current:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: '{{ARCH}}'
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
SCAN: '{{.SCAN}}'
build:darwin:arm64:
internal: false
cmds:
- task: darwin:package:dmg
vars:
ARCH: arm64
BINARY_NAME: macos-arm64
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
APP_BUNDLE: macos-arm64.app
DMG_NAME: macos-arm64.dmg
SCAN: '{{.SCAN}}'
build:darwin:amd64:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: amd64
BINARY_NAME: macos-intel
OUTPUT: '{{.BIN_DIR}}/macos-intel'
APP_BUNDLE: macos-intel.app
DMG_NAME: macos-intel.dmg
SCAN: '{{.SCAN}}'
build:windows:current:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
SCAN: '{{.SCAN}}'
build:windows:386:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: 386
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
ZIP_NAME: windows-32.zip
SCAN: '{{.SCAN}}'
build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
ZIP_NAME: windows-64.zip
SCAN: '{{.SCAN}}'
build:linux:current:
internal: true
cmds:
- task: linux:package:archive
vars:
ARCH: '{{.CURRENT_LINUX_ARCH}}'
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
SCAN: '{{.SCAN}}'
build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: linux-amd64
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
cursor-tab-server:docker:amd64:
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
CONTEXT_DIR: cursor-tab-server
DOCKERFILE: cursor-tab-server/Dockerfile
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f "{{.DOCKERFILE}}"
msg: "未找到 cursor-tab-server/Dockerfile。"
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
msg: "未找到 cursor-tab-server/go.mod。"
cmds:
- mkdir -p "{{.CONTEXT_DIR}}"
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
- ls -lh "{{.IMAGE_TAR}}"
clean:dist:
internal: true
cmds:
- mkdir -p "{{.BIN_DIR}}"
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
dev:
summary: 启动开发模式(前台,终端会保持占用)
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
sources:
- package.json
- yarn.lock
generates:
- node_modules
cmds:
- yarn install --frozen-lockfile
ads:dev:
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/)
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn dev
ads:build:
summary: 构建广告 ZIP 产物
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn build
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
cmds:
- task: common:setup:docker
release:clean:
summary: 清理当前版本的发布目录
cmds:
- rm -rf "{{.RELEASE_DIR}}"
- mkdir -p "{{.RELEASE_DIR}}"
release:ensure:dir:
internal: true
cmds:
- mkdir -p "{{.RELEASE_DIR}}"
release:notes:
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
internal: true
preconditions:
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
msg: "未找到 release-notes.md,请先创建发布日志文件。"
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
msg: "release-notes.md 不能为空,请先填写发布日志。"
cmds:
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
release:build:macos:arm64:
cmds:
- task: darwin:package:archive
vars:
ARCH: arm64
BINARY_NAME: cursor-release-macos-arm64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:macos:amd64:
internal: true
cmds:
- task: darwin:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-macos-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
SCAN: '{{.SCAN}}'
release:build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-linux-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:manifest:
summary: 生成 update.json
internal: true
cmds:
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
release:prepare:
summary: 在当前主机上生成对应的发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
release:prepare:darwin:
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:clean
- task: common:update:build-assets
- task: release:notes
- task: release:build:macos:arm64
- task: release:build:macos:amd64
- task: release:build:windows:amd64
- task: release:build:linux:amd64
release:prepare:linux:
summary: 在当前主机上生成 Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:ensure:dir
- task: common:update:build-assets
- task: release:notes
- task: release:build:linux:amd64
release:verify:assets:
summary: 验证当前版本发布资产是否齐全
preconditions:
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
msg: "缺少 macOS arm64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
msg: "缺少 macOS amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
msg: "缺少 Windows amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
msg: "缺少 Linux amd64 发布资产。"
cmds:
- echo "release assets verified"
release:sync:readme:
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md)
internal: true
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- |
python3 - <<'PY'
import base64
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
readme_path = pathlib.Path("README.md")
content = readme_path.read_bytes()
encoded = base64.b64encode(content).decode("ascii")
get_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"message": "docs: sync README from source repo",
"content": encoded,
"branch": "main",
}
if result.returncode == 0:
existing = json.loads(result.stdout)
existing_content = base64.b64decode(existing["content"])
if existing_content == content:
sys.exit(0)
payload["sha"] = existing["sha"]
put_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
"--method", "PUT",
"--input", "-",
]
completed = run_gh(put_cmd, input_text=json.dumps(payload))
if completed.returncode != 0 and completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
PY
release:github:
summary: 发布当前版本到 GitHub Releases
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- task: release:verify:assets
- task: release:manifest
- task: release:sync:readme
- |
python3 - <<'PY'
import mimetypes
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
version = "{{.APP_VERSION}}"
tag = f"v{version}"
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
assets = sorted([
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
])
get_cmd = [
"gh", "api",
f"repos/{repo}/releases/tags/{tag}",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": tag,
"body": notes,
"draft": False,
"prerelease": False,
}
if result.returncode == 0:
release = json.loads(result.stdout)
release_id = release["id"]
edit_cmd = [
"gh", "api",
f"repos/{repo}/releases/{release_id}",
"--method", "PATCH",
"--input", "-",
]
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
if completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
refreshed = run_gh(get_cmd)
if refreshed.returncode != 0:
if refreshed.stderr:
sys.stderr.write(refreshed.stderr)
sys.exit(refreshed.returncode)
release = json.loads(refreshed.stdout)
else:
create_cmd = [
"gh", "api",
f"repos/{repo}/releases",
"--method", "POST",
"--input", "-",
]
completed = run_gh(create_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
release = json.loads(completed.stdout)
upload_url = release["upload_url"].split("{", 1)[0]
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
for asset in assets:
name = asset.name
if name in existing:
delete_cmd = [
"gh", "api",
f"repos/{repo}/releases/assets/{existing[name]}",
"--method", "DELETE",
]
deleted = run_gh(delete_cmd)
if deleted.returncode != 0:
if deleted.stderr:
sys.stderr.write(deleted.stderr)
sys.exit(deleted.returncode)
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload_cmd = [
"gh", "api",
f"{upload_url}?name={name}",
"--method", "POST",
"--header", f"Content-Type: {content_type}",
"--input", str(asset),
]
uploaded = run_gh(upload_cmd)
if uploaded.returncode != 0:
if uploaded.stderr:
sys.stderr.write(uploaded.stderr)
sys.exit(uploaded.returncode)
PY
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#141414" />
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background-color: var(--vscode-editor-background, var(--startup-background, #141414));
}
</style>
<script>
(() => {
try {
const theme = localStorage.getItem("cursor-byok.theme") === "default-light"
? "default-light"
: "default-dark";
const light = theme === "default-light";
const background = light ? "#fcfcfc" : "#141414";
document.documentElement.dataset.theme = theme;
document.documentElement.style.setProperty("--startup-background", background);
document.documentElement.style.colorScheme = light ? "light" : "dark";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", background);
} catch {
// The inline CSS already provides the dark-theme fallback.
}
})();
</script>
<title>Cursor BYOK</title>
</head>
<body>
<noscript>Cursor BYOK 需要 JavaScript 才能运行。</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
+3505
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.3",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"typecheck": "tsc --noEmit",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
"build": "vite build",
"check": "npm run typecheck && npm run typecheck:node && npm run build",
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
"serve": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
"@iconify/json": "^2.2.519",
"@iconify/react": "^6.0.2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"chart.js": "^4.5.1",
"echarts": "^6.1.0",
"keepalive-for-react": "^5.0.11",
"keepalive-for-react-router": "^5.0.7",
"monaco-editor": "0.56.0",
"react": "^19.2.8",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"zrender": "^6.1.0"
},
"devDependencies": {
"@babel/parser": "^7.29.8",
"@babel/traverse": "^7.29.8",
"@tauri-apps/cli": "^2.11.4",
"@types/babel__traverse": "^7.28.0",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"code-inspector-plugin": "^1.6.6",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"sass": "^1.102.0",
"typescript": "^7.0.2",
"vite": "^8.2.0",
"wait-on": "^9.0.1"
},
"overrides": {
"dompurify": "3.4.14"
}
}
+467
View File
@@ -0,0 +1,467 @@
import { createHash } from "node:crypto"
import fs from "node:fs"
import path from "node:path"
import { parse as parseJavaScript, type ParserPlugin } from "@babel/parser"
import traverseModule from "@babel/traverse"
import { normalizePath, type Plugin } from "vite"
// @ts-expect-error - CJS/ESM 模块互操作。
const traverse = traverseModule.default ?? traverseModule
const SOURCE_LOCALE = "zh-CN"
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
const AUTO_IMPORT_NAME = "__staticI18nT"
const AUTO_IMPORT = `import { t as ${AUTO_IMPORT_NAME} } from "/src/i18n/runtime";\n`
const BABEL_PLUGINS = [
"jsx",
"typescript",
["classProperties", { decoratorsBeforeExport: false }],
["classPrivateProperties", { decoratorsBeforeExport: false }],
"classPrivateMethods",
"topLevelAwait",
"importAttributes"
] as unknown as ParserPlugin[]
interface SourceRef {
file: string
line: number
column: number
}
interface MessageRecord {
id: string
source: string
kind: MessageKind
placeholders: string[]
ref: SourceRef
}
interface MergedEntry {
source: string
kind: MessageKind
placeholders: string[]
refs: SourceRef[]
}
interface CallReplacement {
start: number
end: number
}
interface BabelArgument {
type: string
value?: string
properties?: BabelObjectProperty[]
}
interface BabelObjectProperty {
type: string
computed?: boolean
key?: { type: string; name?: string; value?: string }
}
interface BabelCallNode {
callee: { type: string; name?: string; start?: number | null; end?: number | null }
arguments: BabelArgument[]
loc?: { start: { line: number; column: number } | null } | null
}
interface BabelCallPath {
node: BabelCallNode
scope: { getBinding(name: string): unknown }
}
enum MessageKind {
Text = "text",
Template = "template",
}
enum BabelNodeType {
Identifier = "Identifier",
StringLiteral = "StringLiteral",
ObjectExpression = "ObjectExpression",
ObjectProperty = "ObjectProperty",
}
function hashMessageID(message: string): string {
return createHash("sha256").update(message).digest("hex").slice(0, 16)
}
function stripQuery(id: string): string {
return id.split("?")[0]
}
function isSourceFile(id: string): boolean {
return /\.(?:js|jsx|ts|tsx)$/.test(stripQuery(id))
}
function isExcludedFile(sourceRoot: string, id: string): boolean {
const cleanID = normalizePath(stripQuery(id))
if (cleanID.includes("/node_modules/")) return true
const relativePath = normalizePath(path.relative(sourceRoot, cleanID))
if (relativePath.startsWith("..")) return true
return relativePath.startsWith("i18n/")
}
function readJSONFile(filePath: string): Record<string, string> {
if (!fs.existsSync(filePath)) return {}
const raw = fs.readFileSync(filePath, "utf8").trim()
return raw ? JSON.parse(raw) : {}
}
function writeJSONFile(filePath: string, payload: unknown): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`)
}
function buildRef(
filePath: string,
sourceRoot: string,
loc: { line: number; column: number } | null
): SourceRef {
return {
file: normalizePath(path.relative(sourceRoot, filePath)),
line: loc?.line ?? 1,
column: loc?.column ?? 1
}
}
function sourceLocation(
filePath: string,
sourceRoot: string,
loc: { line: number; column: number } | null
): string {
const ref = buildRef(filePath, sourceRoot, loc)
return `${ref.file}:${ref.line}:${ref.column}`
}
function placeholderNames(message: string): string[] {
return Array.from(message.matchAll(PLACEHOLDER_PATTERN), (match) => match[1])
}
function assertUniquePlaceholders(
placeholders: string[],
location: string,
message: string
): void {
const duplicate = placeholders.find(
(name, index) => placeholders.indexOf(name) !== index
)
if (!duplicate) return
throw new Error(
`[static-i18n] ${location} 重复使用占位符 {${duplicate}}:${JSON.stringify(message)}`
)
}
function objectParameterNames(argument: BabelArgument, location: string): string[] {
if (argument.type !== BabelNodeType.ObjectExpression) {
throw new Error(`[static-i18n] ${location} 的 t params 必须是静态对象字面量`)
}
const names: string[] = []
for (const property of argument.properties ?? []) {
if (property.type !== BabelNodeType.ObjectProperty || property.computed) {
throw new Error(
`[static-i18n] ${location} 的 t params 不允许展开、计算属性或方法`
)
}
const key = property.key
const name =
key?.type === BabelNodeType.Identifier
? key.name
: key?.type === BabelNodeType.StringLiteral
? key.value
: undefined
if (!name) {
throw new Error(`[static-i18n] ${location} 的 t params key 必须是静态名称`)
}
if (names.includes(name)) {
throw new Error(`[static-i18n] ${location} 的 t params 重复提供 ${name}`)
}
names.push(name)
}
return names
}
function validateSourceCall(
filePath: string,
sourceRoot: string,
source: string,
args: BabelArgument[],
loc: { line: number; column: number } | null
): string[] {
const location = sourceLocation(filePath, sourceRoot, loc)
if (!CHINESE_SOURCE_PATTERN.test(source)) {
throw new Error(
`[static-i18n] ${location} 的 t source 必须包含中文:${JSON.stringify(source)}`
)
}
const placeholders = placeholderNames(source)
assertUniquePlaceholders(placeholders, location, source)
if (!placeholders.length) {
if (args.length !== 1) {
throw new Error(`[static-i18n] ${location} 的无参数消息不能传入 params`)
}
return placeholders
}
if (args.length !== 2) {
throw new Error(
`[static-i18n] ${location} 必须为占位符传入一个具名 params 对象`
)
}
const parameters = objectParameterNames(args[1], location)
const missing = placeholders.filter((name) => !parameters.includes(name))
const extra = parameters.filter((name) => !placeholders.includes(name))
if (missing.length || extra.length) {
throw new Error(
`[static-i18n] ${location} 的 params 与占位符不一致` +
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
)
}
return placeholders
}
function buildMessageRecord(
filePath: string,
sourceRoot: string,
source: string,
placeholders: string[],
loc: { line: number; column: number } | null
): MessageRecord {
return {
id: hashMessageID(source),
source,
kind: placeholders.length ? MessageKind.Template : MessageKind.Text,
placeholders,
ref: buildRef(filePath, sourceRoot, loc)
}
}
function mergeMessageRecords(records: MessageRecord[]) {
const entries = new Map<string, MergedEntry>()
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: Record<string, MergedEntry> = {}
for (const id of Array.from(entries.keys()).sort()) {
const entry = entries.get(id)!
sortedEntries[id] = {
...entry,
refs: entry.refs.sort(
(a, b) =>
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column
)
}
}
return { entries: sortedEntries }
}
function mergeLocaleMessages(
existingMessages: Record<string, string>,
catalogEntries: Record<string, MergedEntry>,
locale: string
): Record<string, string> {
return Object.fromEntries(
Object.entries(catalogEntries).map(([id, entry]) => [
id,
locale === SOURCE_LOCALE ? entry.source : existingMessages[id] ?? ""
])
)
}
function validateTranslations(
sourceRoot: string,
entries: Record<string, MergedEntry>,
requireComplete: boolean
): void {
for (const locale of SUPPORTED_LOCALES) {
if (locale === SOURCE_LOCALE) continue
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
const messages = readJSONFile(localePath)
for (const [id, entry] of Object.entries(entries)) {
const translation = messages[id]
if (!translation) {
if (requireComplete) {
throw new Error(
`[static-i18n] ${localePath}:${id} 缺少译文:${entry.source}`
)
}
continue
}
const actual = placeholderNames(translation)
assertUniquePlaceholders(actual, `${localePath}:${id}`, translation)
const missing = entry.placeholders.filter((name) => !actual.includes(name))
const extra = actual.filter((name) => !entry.placeholders.includes(name))
if (!missing.length && !extra.length) continue
throw new Error(
`[static-i18n] ${localePath}:${id} 的译文占位符与 source 不一致` +
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
)
}
}
}
function walkSourceFiles(dirPath: string, visitor: (filePath: string) => void): void {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const nextPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
if (entry.name !== "node_modules") walkSourceFiles(nextPath, visitor)
} else {
visitor(nextPath)
}
}
}
function parseProgram(code: string, filename: string) {
try {
return parseJavaScript(code, {
sourceType: "module",
sourceFilename: filename,
plugins: [...BABEL_PLUGINS]
})
} catch (error) {
throw new Error(
`[static-i18n] Failed to parse ${filename}: ${(error as Error).message}`
)
}
}
function analyzeTCalls(
code: string,
filePath: string,
sourceRoot: string
): { records: MessageRecord[]; replacements: CallReplacement[] } {
const records: MessageRecord[] = []
const replacements: CallReplacement[] = []
const ast = parseProgram(code, filePath)
traverse(ast, {
CallExpression(callPath: BabelCallPath) {
const node = callPath.node
if (node.callee.type !== BabelNodeType.Identifier || node.callee.name !== "t") return
let loc: { line: number; column: number } | null = null
if (node.loc?.start) {
loc = { line: node.loc.start.line, column: node.loc.start.column + 1 }
}
const location = sourceLocation(filePath, sourceRoot, loc)
if (callPath.scope.getBinding("t")) {
throw new Error(
`[static-i18n] ${location} 的 t 是保留全局函数,请删除本地声明或 import`
)
}
if (!node.arguments.length) {
throw new Error(`[static-i18n] ${location} 的 t 调用缺少中文 source`)
}
const firstArg = node.arguments[0]
if (firstArg.type !== BabelNodeType.StringLiteral || firstArg.value === undefined) {
throw new Error(`[static-i18n] ${location} 的 t source 必须是静态字符串字面量`)
}
const placeholders = validateSourceCall(
filePath,
sourceRoot,
firstArg.value,
node.arguments,
loc
)
records.push(
buildMessageRecord(filePath, sourceRoot, firstArg.value, placeholders, loc)
)
const { start, end } = node.callee
if (start == null || end == null) {
throw new Error(`[static-i18n] ${location} 无法定位 t 调用`)
}
replacements.push({ start, end })
}
})
return { records, replacements }
}
function collectCatalog(sourceRoot: string) {
const records: MessageRecord[] = []
walkSourceFiles(sourceRoot, (filePath) => {
const cleanPath = normalizePath(filePath)
if (!isSourceFile(cleanPath) || isExcludedFile(sourceRoot, cleanPath)) return
records.push(
...analyzeTCalls(fs.readFileSync(cleanPath, "utf8"), cleanPath, sourceRoot).records
)
})
return mergeMessageRecords(records)
}
function syncCatalogFiles(
sourceRoot: string,
catalog: ReturnType<typeof collectCatalog>
): void {
writeJSONFile(path.join(sourceRoot, "i18n/generated/catalog.json"), catalog)
for (const locale of SUPPORTED_LOCALES) {
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
writeJSONFile(
localePath,
mergeLocaleMessages(readJSONFile(localePath), catalog.entries, locale)
)
}
}
function injectRuntimeImport(code: string, replacements: CallReplacement[]): string {
let transformed = code
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
transformed =
transformed.slice(0, replacement.start) +
AUTO_IMPORT_NAME +
transformed.slice(replacement.end)
}
return AUTO_IMPORT + transformed
}
export function staticI18nPlugin(): Plugin {
let sourceRoot = path.join(process.cwd(), "src")
const shouldScan =
process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true"
return {
name: "cursor-byok-static-i18n",
enforce: "pre",
configResolved(config) {
sourceRoot = path.join(config.root, "src")
},
buildStart() {
const catalog = collectCatalog(sourceRoot)
if (shouldScan) syncCatalogFiles(sourceRoot, catalog)
validateTranslations(sourceRoot, catalog.entries, !shouldScan)
},
transform(code, id) {
if (!isSourceFile(id) || isExcludedFile(sourceRoot, id)) return null
const analysis = analyzeTCalls(code, normalizePath(stripQuery(id)), sourceRoot)
if (!analysis.replacements.length) return null
return { code: injectRuntimeImport(code, analysis.replacements), map: null }
}
}
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.0-beta.3"
edition = "2021"
publish = false
[lib]
name = "cursor_byok_desktop"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
axum = "0.8"
cursor-server = { path = "../../../server" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-single-instance = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
tauri-plugin-process = "2"
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["time"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
+5
View File
@@ -0,0 +1,5 @@
fn main() {
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
.expect("failed to build Tauri application")
}
@@ -0,0 +1,25 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Cursor BYOK 主窗口的最小权限",
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"allow-open-terminal-with-command",
"clipboard-manager:allow-write-text",
"autostart:default",
"process:allow-restart",
"updater:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-open-terminal-with-command"
description = "Enables the open_terminal_with_command command without any pre-configured scope."
commands.allow = ["open_terminal_with_command"]
[[permission]]
identifier = "deny-open-terminal-with-command"
description = "Denies the open_terminal_with_command command without any pre-configured scope."
commands.deny = ["open_terminal_with_command"]
+229
View File
@@ -0,0 +1,229 @@
use std::{
process::Command,
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
time::Duration,
};
use axum::{
extract::{Extension, Json},
http::StatusCode,
routing::post,
Router,
};
use tauri::{
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
WebviewWindowBuilder,
};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(dev)]
use cursor_server::config::ConsoleSource;
use cursor_server::{App, Config, Result};
#[cfg(not(dev))]
use crate::frontend;
use crate::tray;
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
struct DesktopRuntime {
shutdown: CancellationToken,
server: Mutex<Option<JoinHandle<Result<()>>>>,
exiting: AtomicBool,
}
#[tauri::command]
fn open_terminal_with_command(command: String) -> tauri::Result<()> {
#[cfg(target_os = "macos")]
{
let _ = command;
Command::new("open").args(["-a", "Terminal"]).status()?;
Ok(())
}
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "cmd", "/K", &command])
.spawn()?;
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = command;
Err(tauri::Error::from(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"terminal guidance is unsupported on this platform",
)))
}
}
#[derive(serde::Deserialize)]
struct OpenExternalUrlRequest {
url: String,
}
fn open_external_url(app: &AppHandle, url: &str) -> std::result::Result<(), String> {
let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err("only absolute HTTP and HTTPS URLs are allowed".into());
}
app.opener()
.open_url(parsed.as_str(), None::<&str>)
.map_err(|error| format!("failed to open URL: {error}"))
}
async fn open_external_url_handler(
Extension(app): Extension<AppHandle>,
Json(request): Json<OpenExternalUrlRequest>,
) -> std::result::Result<StatusCode, (StatusCode, String)> {
open_external_url(&app, &request.url)
.map(|_| StatusCode::NO_CONTENT)
.map_err(|error| (StatusCode::BAD_REQUEST, error))
}
fn desktop_api_router(app: AppHandle) -> Router {
Router::new()
.route(
"/__byok-api__/api/desktop/open-external-url",
post(open_external_url_handler),
)
.layer(Extension(app))
}
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
let url = format!("http://{address}/__byok-api__/")
.parse()
.expect("local frontend URL");
let builder = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(url))
.title("Cursor BYOK")
.inner_size(820.0, 558.0)
.min_inner_size(820.0, 558.0)
.center()
.background_color(Color(20, 20, 20, 255))
.decorations(cfg!(target_os = "macos"))
.shadow(true)
.resizable(true);
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
builder.build()?;
Ok(())
}
pub fn run() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_server=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let app = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
tray::show_main_window(app);
}))
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
app.handle().plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))?;
let config = Config::desktop()?;
#[cfg(dev)]
let config = {
let mut config = config;
config.console = Some(ConsoleSource::Proxy(
"http://127.0.0.1:1420"
.parse()
.expect("Vite development URL"),
));
config
};
let server = tauri::async_runtime::block_on(App::new(config))?
.merge_router(desktop_api_router(app.handle().clone()));
#[cfg(not(dev))]
let server = server.merge_router(frontend::router(app.handle().clone()));
let listener = tauri::async_runtime::block_on(server.bind())?;
let address = listener.local_addr()?;
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
let app_handle = app.handle().clone();
let task = tauri::async_runtime::spawn(async move {
let result = server.serve_on(listener, server_shutdown).await;
if let Err(error) = &result {
tracing::error!(%error, "desktop server stopped unexpectedly");
app_handle.exit(1);
}
result
});
app.manage(DesktopRuntime {
shutdown,
server: Mutex::new(Some(task)),
exiting: AtomicBool::new(false),
});
create_main_window(app.handle(), address)?;
tray::create(app)?;
Ok(())
})
.build(tauri::generate_context!())
.expect("failed to build Cursor BYOK desktop app");
app.run(|app, event| match event {
RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} if label == MAIN_WINDOW_LABEL => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.load(Ordering::Acquire) {
api.prevent_close();
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.hide();
}
}
}
RunEvent::ExitRequested { api, .. } => {
let runtime = app.state::<DesktopRuntime>();
if !runtime.exiting.swap(true, Ordering::AcqRel) {
api.prevent_exit();
runtime.shutdown.cancel();
let server = runtime.server.lock().expect("server lock poisoned").take();
let app = app.clone();
tauri::async_runtime::spawn(async move {
if let Some(server) = server {
match tokio::time::timeout(Duration::from_secs(11), server).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(error))) => {
tracing::error!(%error, "desktop server shutdown failed")
}
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
Err(_) => tracing::warn!("desktop server shutdown timed out"),
}
}
app.exit(0);
});
}
}
#[cfg(target_os = "macos")]
RunEvent::Reopen {
has_visible_windows: false,
..
} => tray::show_main_window(app),
_ => {}
});
}
+43
View File
@@ -0,0 +1,43 @@
//! Serves Tauri's embedded frontend assets through the local HTTP server.
use axum::{
body::Body,
extract::State,
http::{header, HeaderValue, Response, StatusCode, Uri},
routing::get,
Router,
};
use tauri::AppHandle;
pub(crate) fn router(app: AppHandle) -> Router {
Router::new()
.route("/__byok-api__/", get(asset))
.route("/__byok-api__/{*path}", get(asset))
.with_state(app)
}
async fn asset(State(app): State<AppHandle>, uri: Uri) -> Response<Body> {
let path = uri
.path()
.strip_prefix("/__byok-api__/")
.filter(|path| !path.is_empty())
.unwrap_or("index.html");
let Some(asset) = app.asset_resolver().get(path.to_string()) else {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("static not-found response");
};
let mut response = Response::new(Body::from(asset.bytes));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&asset.mime_type)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
if let Some(csp) = asset.csp_header.and_then(|value| value.parse().ok()) {
response
.headers_mut()
.insert(header::CONTENT_SECURITY_POLICY, csp);
}
response
}
+6
View File
@@ -0,0 +1,6 @@
mod desktop;
#[cfg(not(dev))]
mod frontend;
mod tray;
pub use desktop::run;
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cursor_byok_desktop::run();
}
+55
View File
@@ -0,0 +1,55 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
App, AppHandle, Manager,
};
#[cfg(target_os = "windows")]
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
use crate::desktop::MAIN_WINDOW_LABEL;
const OPEN_MENU_ID: &str = "tray-open";
const QUIT_MENU_ID: &str = "tray-quit";
pub fn create(app: &mut App) -> tauri::Result<()> {
let open = MenuItem::with_id(app, OPEN_MENU_ID, "打开 Cursor BYOK", true, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, QUIT_MENU_ID, "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &separator, &quit])?;
TrayIconBuilder::with_id("main")
.icon(tauri::include_image!("./icons/32x32.png"))
.tooltip("Cursor BYOK")
.menu(&menu)
.show_menu_on_left_click(cfg!(target_os = "macos"))
.on_menu_event(|app, event| match event.id().as_ref() {
OPEN_MENU_ID => show_main_window(app),
QUIT_MENU_ID => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
#[cfg(target_os = "windows")]
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
#[cfg(not(target_os = "windows"))]
let _ = (tray, event);
})
.build(app)?;
Ok(())
}
pub fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.0-beta.3",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
"dangerousDisableAssetCspModification": [
"style-src"
]
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/linux-32x32.png",
"icons/linux-128x128.png",
"icons/linux-256x256.png",
"icons/linux-512x512.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1MEY2RjQzRjA0MjhCMDUKUldRRmkwTHdRMjhQZGJJam9zT1NwcW83L1V4ckJvbkQzQ0FYVFZ6MGRpa1N2NVFkUHk1dW14c1AK",
"endpoints": [
"https://github.com/leookun/cursor-byok/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
}
}
@@ -0,0 +1,5 @@
{
"bundle": {
"targets": ["app"]
}
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { MessageProvider } from "./components/ui/MessageProvider";
import { useMessage } from "./components/ui/message";
import { AppFrame } from "./layouts/AppFrame";
import { AppLayout } from "./layouts/AppLayout";
import { CallsPage } from "./pages/CallsPage";
import { CallDetailsPage } from "./pages/CallDetailsPage";
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
import { HomePage } from "./pages/HomePage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { SettingsPage } from "./pages/SettingsPage";
import { useAppStore } from "./store/appStore";
import { updateStore } from "./store/updateStore";
export function App() {
return (
<>
<HashRouter>
<Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} />
<Route element={<AppFrame />}>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="providers" element={<ProvidersPage />} />
<Route path="calls" element={<CallsPage />} />
<Route path="harness/cursor" element={<CursorSettingsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>
<AppMessages />
</>
);
}
function AppMessages() {
const { error } = useAppStore();
const previousError = useRef<string | null>(null);
const showMessage = useMessage();
useEffect(() => {
if (error && error !== previousError.current) showMessage(error);
previousError.current = error;
}, [error, showMessage]);
useEffect(() => {
void updateStore.check().then((version) => {
if (!version) return;
showMessage(t("发现新版本 {version},可在设置中安装", { version }), { duration: 6_000 });
}).catch(() => {
// Startup checks are best-effort; manual checks in Settings report errors.
});
}, [showMessage]);
return <MessageProvider />;
}
+295
View File
@@ -0,0 +1,295 @@
import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime";
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
export interface Provider {
provider_id: number;
name: string;
provider_type: ProviderType;
base_url: string;
has_api_key: boolean;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
created_at_ms: number;
updated_at_ms: number;
}
export interface ProviderInput {
name: string;
provider_type: ProviderType;
base_url: string;
api_key?: string;
custom_headers: Record<string, string | null>;
extra_params: Record<string, unknown>;
}
export interface Model {
model_hash: string;
provider_id: number;
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number;
context_window_tokens: number | null;
max_output_tokens: number | null;
reasoning_enabled: boolean;
reasoning_effort: string | null;
supports_image_generation: boolean;
created_at_ms: number;
updated_at_ms: number;
}
export interface ModelInput {
model_id: string;
display_name: string;
endpoint_type: ProviderType;
request_url: string;
enabled: boolean;
sort_order: number;
context_window_tokens: number | null;
max_output_tokens: number | null;
reasoning_enabled: boolean;
reasoning_effort: string | null;
supports_image_generation: boolean;
}
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
export type IntegrationState = "disabled" | "enabled" | "degraded";
export interface CursorHarnessStatus {
platform: string;
ca: CaState;
configured_models: number;
enabled_models: number;
integration: IntegrationState;
proxy_url: string | null;
ca_install_command: string | null;
}
export interface PortSettings {
proxy_port: number;
service_port: number;
}
export interface StatisticsStorage {
bytes: number;
call_count: number;
trace_count: number;
}
export type ProxyMode = "system" | "custom";
export interface ProxySettings {
mode: ProxyMode;
address: string;
auth_enabled: boolean;
username: string;
has_password: boolean;
}
export interface ProxySettingsInput {
mode: ProxyMode;
address: string;
auth_enabled: boolean;
username: string;
password?: string;
}
export type TabMode = "public" | "direct" | "custom";
export interface TabSettings {
mode: TabMode;
address: string;
}
export interface OverviewMetrics {
llm_calls: number;
successful_calls: number;
failed_calls: number;
token_usage: number;
prompt_tokens: number;
input_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
output_tokens: number;
}
export type TokenUsageGranularity = "minute" | "hour" | "day";
export interface OverviewTokenUsageBucket {
bucket_start_ms: number;
input_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
output_tokens: number;
}
export interface Overview {
metrics: OverviewMetrics;
token_usage_granularity: TokenUsageGranularity;
token_usage_series: OverviewTokenUsageBucket[];
}
export type ProviderSelection =
| { kind: "existing"; provider_id: number }
| { kind: "new"; input: ProviderInput };
export interface LlmCall {
call_kind: "provider_llm" | "cursor_official";
route: "local_byok" | "cursor_official";
call_id: string;
run_id: string;
conversation_id: string;
provider_call_index: number;
model_hash: string | null;
provider_type: string;
provider_url: string;
request_type: string;
request_url: string;
model_id: string;
display_name: string;
reasoning_effort: string | null;
fast: boolean | null;
status: string;
finish_reason: string | null;
created_at_ms: number;
ttfb_ms: number | null;
ttft_ms: number | null;
duration_ms: number | null;
input_tokens: number | null;
output_tokens: number | null;
total_tokens: number | null;
cache_read_tokens: number | null;
cache_write_tokens: number | null;
reasoning_tokens: number | null;
message_count: number;
tool_count: number;
http_status: number | null;
error_kind: string | null;
error_message: string | null;
detailed: boolean;
}
export interface CallDetail {
call: LlmCall;
request: { headers: unknown; body: unknown; byte_count: number } | null;
response_chunks: Array<{ seq: number; received_offset_ms: number; data: string; byte_count: number }>;
cursor_trace: {
trace: {
request_id: string;
conversation_id: string | null;
route: "local_byok" | "cursor_official";
model_id: string | null;
status: string;
request_bytes: number;
response_bytes: number;
response_event_count: number;
http_status: number | null;
received_at_ms: number;
first_response_at_ms: number | null;
finished_at_ms: number | null;
error_message: string | null;
};
artifacts: Array<{
seq: number;
artifact_type: string;
source: string;
metadata: unknown;
created_at_ms: number;
byte_count: number;
encoding: "utf8" | "base64";
data: string;
}>;
} | null;
}
const packagedDesktop = "__TAURI_INTERNALS__" in window
|| window.location.protocol === "tauri:"
|| window.location.hostname === "tauri.localhost";
const API_ROOT = "/__byok-api__/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_ROOT}${path}`, {
...init,
headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers,
});
} catch (cause) {
throw new Error(t("无法连接本地管理服务"), { cause });
}
if (!response.ok) {
const message = await response.text();
throw new Error(message || `${response.status} ${response.statusText}`);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const api = {
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
const value = [...disabledAdIds].join(",");
return request<AdRuntime>("/ads", {
headers: {
"accept-language": locale,
...(value ? { "disable-ad-ids": value } : {}),
},
});
},
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
providers: () => request<Provider[]>("/providers"),
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
models: () => request<Model[]>("/models"),
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
const params = new URLSearchParams();
if (filter) {
params.set("start_ms", String(filter.startMs));
params.set("end_ms", String(filter.endMs));
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
}
const query = params.size ? `?${params}` : "";
return request<Overview>(`/overview${query}`);
},
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
openCursorCaInstallTerminal: async (command: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
const { invoke } = await import("@tauri-apps/api/core");
await invoke("open_terminal_with_command", { command });
},
copyCursorText: async (text: string) => {
if (!packagedDesktop) throw new Error(t("请在桌面应用中复制到系统剪贴板"));
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
await writeText(text);
},
setCursorEnabled: (enabled: boolean) => request<CursorHarnessStatus>("/harness/cursor/enabled", { method: "PUT", body: JSON.stringify({ enabled }) }),
calls: () => request<LlmCall[]>("/llm-calls?limit=200"),
call: (id: string) => request<CallDetail>(`/llm-calls/${encodeURIComponent(id)}`),
openCallDetails: async (id: string) => {
const url = new URL(window.location.href);
url.hash = `/calls/${encodeURIComponent(id)}`;
await request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url: url.toString() }) });
},
openExternalUrl: (url: string) => request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url }) }),
observability: () => request<{ detailed: boolean }>("/settings/observability"),
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
ports: () => request<PortSettings>("/settings/ports"),
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
proxySettings: () => request<ProxySettings>("/settings/proxy"),
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
tabSettings: () => request<TabSettings>("/settings/tab"),
setTabSettings: (settings: TabSettings) => request<TabSettings>("/settings/tab", { method: "PUT", body: JSON.stringify(settings) }),
};
+20
View File
@@ -0,0 +1,20 @@
<svg width="310" height="310" viewBox="0 0 310 310" xmlns="http://www.w3.org/2000/svg">
<title>Cursor</title>
<defs>
<linearGradient x1="12.6410966%" y1="0%" x2="93.9769479%" y2="100%" id="cursor-border-gradient">
<stop stop-color="#C8C8C8" offset="0%"/>
<stop stop-color="#000000" offset="45.1598984%"/>
<stop stop-color="#979797" offset="100%"/>
</linearGradient>
</defs>
<g stroke="none" fill="none" fill-rule="evenodd" transform="translate(21 21)">
<path stroke="url(#cursor-border-gradient)" stroke-width="3" stroke-linejoin="square" fill="#000000" d="M206.569725 2.38059035c9.666379.78977439 16.750829 2.27231033 22.749786 4.86175573l2.508886 1.17873983c11.948293 6.08795909 21.662558 15.80222449 27.750517 27.75051739c3.330466 6.5364062 5.137896 14.2113817 6.040496 25.2586721c.748007 9.1551756.88059 17.5905616.88059 42.5697246v60c0 24.979163-.132583 33.414549-.88059 42.569725c-.9026 11.04729-2.71003 18.722265-6.040496 25.258672c-6.087959 11.948293-15.802224 21.662558-27.750517 27.750517c-6.536407 3.330466-14.211382 5.137896-25.258672 6.040496c-9.155176.748007-17.590562.88059-42.569725.88059h-60c-24.979163 0-33.414549-.132583-42.569725-.88059c-11.04729-.9026-18.722265-2.71003-25.258672-6.040496c-11.948293-6.087959-21.662558-15.802224-27.750517-27.750517c-3.330465-6.536407-5.137896-14.211382-6.040496-25.258672C1.632583 197.414549 1.5 188.979163 1.5 164v-60c0-24.979163.132583-33.414549.88059-42.569725c.9026-11.0472904 2.71003-18.7222659 6.040496-25.2586721C14.509045 24.2233104 24.22331 14.509045 36.171603 8.42108571c6.536407-3.33046534 14.211382-5.13789606 25.258672-6.04049536C70.585451 1.63258295 79.020837 1.5 104 1.5h60c24.979163 0 33.414549.13258295 42.569725.88059035Z"/>
<g transform="translate(24.9831 26.1186)">
<path d="M0 0h216.898305v216.898305H0z"/>
<path fill="#444444" fill-rule="nonzero" d="M26.0277966 61.6955179v94.4712621H192.316497V61.6955179L109.172147 14.459887"/>
<path fill="#939393" fill-rule="nonzero" d="M109.172147 109.172147V14.459887L26.0277966 61.8160169L192.316497 156.528277l-83.14435 47.35613l-83.1443504-47.35613"/>
<path fill="#E3E3E3" fill-rule="nonzero" d="M190.870508 60.7315254h-82.421355V202.438418"/>
<path fill="#FFFFFF" fill-rule="nonzero" d="M26.0277966 60.7315254H192.316497l-83.14435 47.7176276"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,38 @@
@use "../styles/typography" as type;
.root {
section { min-width: 0; }
h4 { margin: 12px 0 6px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; font-weight: 500; }
}
.details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
padding: 1px;
margin: 0;
overflow: hidden;
background: var(--vscode-sideBar-border);
border-radius: 6px;
> div {
min-width: 0;
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: 10px;
padding: 8px 10px;
background: var(--vscode-editorWidget-background);
}
dt { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
code { white-space: pre-wrap; font-family: var(--oa-code-font); font-size: type.$font-size-xs; }
}
.meta{
margin-bottom: 10px;
}
.meta,
.empty {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
@@ -0,0 +1,81 @@
import type { CallDetail } from "../api";
import { JsonEditor } from "./ui/JsonEditor";
import { Tabs, type TabItem } from "./ui/Tabs";
import styles from "./CallDetails.module.scss";
const show = (value: string | number | null) => value ?? "-";
const timing = (value: number | null) => value == null ? "-" : `${value} ms`;
export function CallDetails({ detail }: { detail: CallDetail }) {
const { call, request, response_chunks: chunks, cursor_trace: cursorTrace } = detail;
const responseBody = chunks.map((chunk) => chunk.data).join("");
const responseBytes = chunks.reduce((total, chunk) => total + chunk.byte_count, 0);
const fields: Array<[string, string | number]> = [
["Call ID", call.call_id],
[t("调用类型"), call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM"],
[t("路由"), call.route === "cursor_official" ? t("Cursor 官方") : "BYOK"],
["Run ID", call.run_id],
["Conversation ID", call.conversation_id],
[t("上游调用序号"), call.provider_call_index],
["Model Hash", show(call.model_hash)],
[t("上游类型"), call.provider_type],
[t("上游地址"), call.provider_url],
[t("最终请求类型"), call.request_type],
[t("最终请求地址"), call.request_url],
["Model ID", call.model_id],
[t("显示名称"), call.display_name],
[t("思考强度"), show(call.reasoning_effort)],
["Fast", call.fast == null ? "-" : call.fast ? t("是") : t("否")],
[t("状态"), call.status],
["Finish Reason", show(call.finish_reason)],
["HTTP Status", show(call.http_status)],
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
[t("耗时"), timing(call.duration_ms)],
["TTFB", timing(call.ttfb_ms)],
["TTFT", timing(call.ttft_ms)],
["Input Token", show(call.input_tokens)],
["Output Token", show(call.output_tokens)],
["Total Token", show(call.total_tokens)],
["Cache Read Token", show(call.cache_read_tokens)],
["Cache Write Token", show(call.cache_write_tokens)],
["Reasoning Token", show(call.reasoning_tokens)],
[t("消息数"), call.message_count],
[t("工具数"), call.tool_count],
[t("详细记录"), call.detailed ? t("是") : t("否")],
["Error Kind", show(call.error_kind)],
["Error Message", show(call.error_message)],
];
const tabs: TabItem[] = [
{ value: "call", label: t("调用信息"), content: <section>
<dl className={styles.details}>{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>
</section> },
{ value: "request", label: t("请求"), content: <section>
{request ? <>
<div className={styles.meta}>{t("字节数")}:{request.byte_count}</div>
<h4>{t("请求头")}</h4><JsonEditor ariaLabel={t("请求头")} value={JSON.stringify(request.headers)} readOnly />
<h4>{t("请求体")}</h4><JsonEditor ariaLabel={t("请求体")} value={JSON.stringify(request.body)} readOnly detail />
</> : <div className={styles.empty}>{t("未记录请求内容,请开启详细记录后重试。")}</div>}
</section> },
{ value: "response", label: t("响应流"), content: <section>
{chunks.length > 0 ? <>
<div className={styles.meta}>{t("分块数")}:{chunks.length} · {t("字节数")}:{responseBytes}</div>
<JsonEditor ariaLabel={t("响应流")} value={responseBody} readOnly detail />
</> : <div className={styles.empty}>{t("未记录响应内容,请开启详细记录后重试。")}</div>}
</section> },
];
if (cursorTrace) tabs.push({ value: "cursor-trace", label: t("Cursor 追踪"), content: <section>
<div className={styles.meta}>
Request ID:{cursorTrace.trace.request_id} · {t("工件数")}:{cursorTrace.artifacts.length}
</div>
<JsonEditor
ariaLabel={t("Cursor 追踪")}
value={JSON.stringify({ trace: cursorTrace.trace, artifacts: cursorTrace.artifacts })}
readOnly
detail
/>
</section> });
return <div className={styles.root}><Tabs items={tabs} /></div>;
}
@@ -0,0 +1,12 @@
@use "../styles/typography" as type;
.status {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.completed { color: #56b870; }
.failed { color: var(--vscode-errorForeground, #f48771); }
+194
View File
@@ -0,0 +1,194 @@
import type { LlmCall } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { eyeIcon } from "./ui/icons";
import styles from "./CallTable.module.scss";
const value = (input: string | number | null) => input ?? "-";
const milliseconds = (input: number | null) => input == null ? "-" : `${input} ms`;
export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (call: LlmCall) => void }) {
const columns: DataTableColumn<LlmCall>[] = [
{
key: "status",
header: t("状态"),
render: (call) => (
<span
className={[styles.status, styles[call.status]]
.filter(Boolean)
.join(" ")}
>
{call.status}
</span>
),
},
{
key: "display_name",
header: t("显示名称"),
render: (call) => call.display_name,
title: (call) => call.display_name,
},
{
key: "created_at",
header: t("时间"),
render: (call) => new Date(call.created_at_ms).toLocaleString(),
},
{
key: "model_id",
header: t("模型名称"),
render: (call) => call.model_id,
title: (call) => call.model_id,
},
{
key: "reasoning_effort",
header: t("思考强度"),
render: (call) => value(call.reasoning_effort),
},
{
key: "fast",
header: "Fast",
render: (call) => call.fast == null ? "-" : call.fast ? t("是") : t("否"),
},
{
key: "call_kind",
header: t("调用类型"),
render: (call) =>
call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM",
},
{
key: "route",
header: t("路由"),
render: (call) =>
call.route === "cursor_official" ? t("Cursor 官方") : "BYOK",
},
// { key: "model_hash", header: "Model Hash", render: (call) => value(call.model_hash), title: (call) => call.model_hash ?? undefined },
// { key: "provider_type", header: t("供应商类型"), render: (call) => call.provider_type },
// { key: "provider_url", header: t("供应商地址"), render: (call) => call.provider_url, title: (call) => call.provider_url },
// { key: "request_type", header: t("最终请求类型"), render: (call) => call.request_type },
// { key: "request_url", header: t("最终请求地址"), render: (call) => call.request_url, title: (call) => call.request_url },
{
key: "finish_reason",
header: "Finish Reason",
render: (call) => value(call.finish_reason),
},
{ key: "http", header: "HTTP", render: (call) => value(call.http_status) },
{
key: "duration",
header: t("耗时"),
render: (call) => milliseconds(call.duration_ms),
},
{
key: "ttfb",
header: "TTFB",
render: (call) => milliseconds(call.ttfb_ms),
},
{
key: "ttft",
header: "TTFT",
render: (call) => milliseconds(call.ttft_ms),
},
{
key: "input_tokens",
header: "Input Token",
render: (call) => value(call.input_tokens),
},
{
key: "output_tokens",
header: "Output Token",
render: (call) => value(call.output_tokens),
},
{
key: "total_tokens",
header: "Total Token",
render: (call) => value(call.total_tokens),
},
{
key: "cache_read",
header: "Cache Read",
render: (call) => value(call.cache_read_tokens),
},
{
key: "cache_write",
header: "Cache Write",
render: (call) => value(call.cache_write_tokens),
},
{
key: "reasoning_tokens",
header: "Reasoning Token",
render: (call) => value(call.reasoning_tokens),
},
{
key: "message_count",
header: t("消息数"),
render: (call) => call.message_count,
},
{
key: "tool_count",
header: t("工具数"),
render: (call) => call.tool_count,
},
{
key: "detailed",
header: t("详细记录"),
render: (call) => (call.detailed ? t("是") : t("否")),
},
{
key: "error_kind",
header: "Error Kind",
render: (call) => value(call.error_kind),
title: (call) => call.error_kind ?? undefined,
},
{
key: "error_message",
header: "Error Message",
render: (call) => value(call.error_message),
title: (call) => call.error_message ?? undefined,
},
{
key: "call_id",
header: "Call ID",
render: (call) => call.call_id,
title: (call) => call.call_id,
},
{
key: "run_id",
header: "Run ID",
render: (call) => call.run_id,
title: (call) => call.run_id,
},
{
key: "conversation_id",
header: "Conversation ID",
render: (call) => call.conversation_id,
title: (call) => call.conversation_id,
},
{
key: "call_index",
header: "Call Index",
render: (call) => call.provider_call_index,
},
{
key: "actions",
header: t("操作"),
sticky: "right",
render: (call) => (
<TooltipTrigger label={t("查看详情")}>
<button
className={controls.iconButton}
aria-label={t("查看详情")}
onClick={() => onDetails(call)}
>
<Icon icon={eyeIcon} size="1.1em" />
</button>
</TooltipTrigger>
),
},
];
return <DataTable rows={calls} columns={columns} rowKey={(call) => call.call_id} minWidth={4180} />;
}
@@ -0,0 +1,15 @@
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.form { grid-template-columns: 1fr; }
.fullWidth { grid-column: auto; }
}
@@ -0,0 +1,30 @@
import type { ProviderInput, ProviderType } from "../api";
import { FormField, TextInput } from "./ui/FormControls";
import { JsonEditor } from "./ui/JsonEditor";
import { Select } from "./ui/Select";
import { claudeIcon, openAiIcon } from "./ui/icons";
import styles from "./ProviderEditor.module.scss";
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
value: ProviderInput;
headersText: string;
extraText: string;
editing: boolean;
onChange: (value: ProviderInput) => void;
onHeadersChange: (value: string) => void;
onExtraChange: (value: string) => void;
}) {
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
return <div className={styles.form}>
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><TextInput type="password" autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
</div>;
}
@@ -0,0 +1,14 @@
@use "../styles/typography" as type;
.badge {
padding: 3px 7px;
color: var(--vscode-descriptionForeground);
background: var(--vscode-input-background);
border-radius: 99px;
font-size: type.$font-size-xs;
}
.actions {
display: flex;
gap: 2px;
}
@@ -0,0 +1,31 @@
import type { Provider } from "../api";
import controls from "./ui/Controls.module.scss";
import { DataTable, type DataTableColumn } from "./ui/DataTable";
import { Icon } from "./ui/Icon";
import { TooltipTrigger } from "./ui/TooltipTrigger";
import { editIcon, trashIcon } from "./ui/icons";
import styles from "./ProviderTable.module.scss";
const json = (value: unknown) => JSON.stringify(value);
export function ProviderTable({ providers, onEdit, onDelete }: {
providers: Provider[];
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
}) {
const columns: DataTableColumn<Provider>[] = [
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
</div> },
];
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
}
@@ -0,0 +1,54 @@
import type { RefObject } from "react";
import { Icon } from "../ui/Icon";
import { windowCloseIcon } from "../ui/icons";
import type { AdSlot } from "./types";
import styles from "./Ads.module.scss";
type AdMenuProps = {
ads: AdSlot[];
activeAdId?: string;
dismissingAdId?: string;
readAdIds: ReadonlySet<string>;
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
onOpen: (ad: AdSlot) => void;
onDismiss: (ad: AdSlot) => void;
};
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
<button
ref={(node) => {
if (node) triggerRefs.current.set(ad.id, node);
else triggerRefs.current.delete(ad.id);
}}
type="button"
className={styles.menuSlot}
title={`${ad.target.title}\n${ad.target.description}`}
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
aria-haspopup="dialog"
aria-expanded={activeAdId === ad.id}
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
onClick={() => onOpen(ad)}
>
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
<span className={styles.menuCopy}>
<span className={styles.menuTitle}>{ad.target.title}</span>
<span className={styles.menuSubtitle}>{ad.target.description}</span>
</span>
</button>
<button
type="button"
className={styles.menuDismiss}
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
aria-haspopup="dialog"
aria-expanded={dismissingAdId === ad.id}
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
onClick={() => onDismiss(ad)}
>
<Icon icon={windowCloseIcon} size="1em" />
</button>
</div>)}
</div>;
}
@@ -0,0 +1,272 @@
@use "../../styles/typography" as type;
.menuSlots {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
}
.menuSlotContainer {
position: relative;
height: 42px;
}
.menuSlot {
position: relative;
width: 100%;
min-width: 0;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 0 8px;
color: inherit;
text-align: left;
background: transparent;
border: 1px solid var(--vscode-sideBar-border);
border-radius: 6px;
&:hover,
&[aria-expanded="true"] {
background: var(--vscode-list-hoverBackground);
border: 1px solid var(--vscode-sideBar-border);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
}
.menuDismiss {
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
background: transparent;
border: 0;
border-radius: 4px;
&:hover {
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
}
}
.unreadDot {
position: absolute;
top: 5px;
right: 25px;
width: 7px;
height: 7px;
background: var(--vscode-errorForeground);
border-radius: 50%;
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
}
.menuImage {
flex: 0 0 30px;
width: 30px;
height: 30px;
display: block;
}
.menuCopy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.menuTitle,
.menuSubtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menuTitle {
font-size: type.$font-size-xs;
font-weight: 500;
}
.menuSubtitle {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
}
.floatingAd {
position: fixed;
z-index: 20000;
left: 16px;
bottom: 16px;
width: 320px;
max-width: calc(100vw - 32px);
height: min(360px, calc(100vh - 32px));
max-height: 360px;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--vscode-foreground);
background: var(--vscode-editorWidget-background);
border-radius: 15px;
border: none;
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
animation: ad-enter 180ms ease-out;
&:focus {
outline: none;
}
}
.hero {
flex: 0 0 112px;
position: relative;
height: 112px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
overflow: hidden;
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
img {
object-fit: cover;
}
}
.promotionLabel {
position: absolute;
top: 10px;
left: 10px;
padding: 3px 7px;
color: white;
font-size: type.$font-size-xs;
line-height: 1;
background: rgb(0 0 0 / 48%);
border-radius: 4px;
}
.close {
position: absolute;
top: 12px;
right: 12px;
width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: type.$font-size-xl;
line-height: 1;
background: rgb(0 0 0 / 16%);
border: 0;
border-radius: 50%;
&:hover {
background: rgb(0 0 0 / 30%);
}
}
.body {
--scroll-shadow-bottom-size: 16px;
flex: 1 1 auto;
min-height: 0;
h2,
h3,
p {
margin: 0;
}
}
.bodyContent {
padding: 14px 16px 18px;
}
.intro {
h2,
p {
margin: 0;
}
h2 {
font-size: type.$font-size-base;
font-weight: 600;
}
p {
margin-top: 6px;
color: var(--vscode-descriptionForeground);
}
}
.detail {
h3,
p {
margin: 0;
}
h3 {
font-size: type.$font-size-base;
font-weight: 500;
}
p {
margin-top: 4px;
color: var(--vscode-descriptionForeground);
}
}
.footer {
flex: 0 0 auto;
padding: 10px 12px 12px;
border-top: 1px solid var(--vscode-sideBar-border);
button {
width: 100%;
height: 34px;
color: #202020;
background: #f2f2f2;
border: 0;
border-radius: 8px;
&:focus-visible {
outline: 2px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
}
}
@keyframes ad-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
}
@media (prefers-reduced-motion: reduce) {
.floatingAd {
animation: none;
}
}
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { Icon } from "../ui/Icon";
import { windowCloseIcon } from "../ui/icons";
import { VirtualList } from "../virtual/VirtualList";
import type { AdAction, AdSlot } from "./types";
import styles from "./Ads.module.scss";
type AdTextSection =
| { key: "intro"; kind: "intro" }
| { key: string; kind: "detail"; label: string; value: string };
type FloatingAdProps = {
ad: AdSlot;
trigger: HTMLButtonElement | null;
onClose: () => void;
onAction: (action: AdAction) => void;
};
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
const dialog = useRef<HTMLDivElement>(null);
const textSections = useMemo<AdTextSection[]>(() => [
{ key: "intro", kind: "intro" },
...ad.content.details.map((detail, index) => ({
key: `detail-${index}-${detail.label}`,
kind: "detail" as const,
label: detail.label,
value: detail.value,
})),
], [ad.content.details]);
useEffect(() => {
dialog.current?.focus();
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
onClose();
trigger?.focus();
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, trigger]);
return createPortal(<div
id="menu-ad-dialog"
ref={dialog}
className={styles.floatingAd}
role="dialog"
aria-modal="false"
aria-label={ad.content.title}
tabIndex={-1}
>
<div className={styles.hero}>
<img src={ad.content.imageUrl} alt="" />
<span className={styles.promotionLabel}>{t("推广")}</span>
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>
</div>
<VirtualList
items={textSections}
itemKey="key"
estimatedItemHeight={58}
itemGap={12}
className={`${styles.body} scroll-shadow-bottom`}
contentClassName={styles.bodyContent}
>
{(section) => section.kind === "intro"
? <div className={styles.intro}>
<h2>{ad.content.title}</h2>
<p>{ad.content.description}</p>
</div>
: <div className={styles.detail}>
<h3>{section.label}</h3>
<p>{section.value}</p>
</div>}
</VirtualList>
<div className={styles.footer}>
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
</div>
</div>, document.body);
}
+36
View File
@@ -0,0 +1,36 @@
export const AdActionType = {
OpenBrowser: "open_browser",
} as const;
export type AdAction = {
type: typeof AdActionType.OpenBrowser;
url: string;
};
export type AdSlot = {
id: string;
enabled: boolean;
placement: "menu";
target: {
title: string;
description: string;
imageUrl: string;
};
content: {
title: string;
description: string;
imageUrl: string;
details: Array<{
label: string;
value: string;
}>;
button: {
label: string;
action: AdAction;
};
};
};
export type AdRuntime = {
slots: AdSlot[];
};
@@ -0,0 +1,36 @@
@use "../../styles/typography" as type;
.root {
width: 100%;
}
.scroller {
width: 100%;
overflow: hidden;
}
.canvas {
width: 100%;
flex: 0 0 auto;
}
.axis {
position: relative;
width: 100%;
height: 18px;
margin-top: 10px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-2xs;
span {
position: absolute;
top: 0;
width: 28px;
white-space: nowrap;
}
}
.tooltipContent {
display: flex;
flex-direction: column;
}
@@ -0,0 +1,272 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = {
date: string;
tokens: number;
};
type ContributionCalendarChartProps = {
data: ContributionDay[];
};
type CalendarCell = ContributionDay & {
column: number;
row: number;
level: number;
};
type CellExtra = CalendarCell & {
kind: "calendar-cell";
x: number;
y: number;
width: number;
height: number;
};
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = {
key: string;
text: string;
left: number;
};
const levelColors = [
"rgba(139, 148, 158, 0.20)",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39",
];
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const CALENDAR_CONFIG = {
cellAspectRatio: 0.9,
cellGap: 3,
resizeTransitionMs: 180,
rowCount: 7,
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
function mondayIndex(date: Date) {
return (date.getUTCDay() + 6) % 7;
}
function cellOffset(index: number, cellSize: number) {
return index * (cellSize + CALENDAR_CONFIG.cellGap);
}
function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[]) {
if (data.length === 0) return null;
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
const cells: CalendarCell[] = data.map((day) => {
const date = parseDate(day.date);
const daysFromStart = Math.round((date.getTime() - calendarStart.getTime()) / DAY_IN_MS);
const level = day.tokens === 0 ? 0 : Math.max(1, Math.ceil((day.tokens / maximum) * 4));
return { ...day, column: Math.floor(daysFromStart / 7), row: mondayIndex(date), level };
});
const columnCount = cells.at(-1)!.column + 1;
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data), [data]);
layoutRef.current = layout;
useLayoutEffect(() => {
const scroller = scrollerRef.current;
const node = canvasRef.current;
if (!scroller || !node) return;
const chart = init(node, {
renderer: "canvas",
width: 1,
height: 1,
useDirtyRect: true,
});
const handleMouseOver = (event: ElementEvent) => {
const extra = event.target?.extra;
if (!isCellExtra(extra)) return;
const anchor: TooltipAnchor = {
contextElement: node,
getBoundingClientRect: () => {
const bounds = node.getBoundingClientRect();
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
},
};
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
};
const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) setTooltip(null);
};
chart.on("mouseover", handleMouseOver);
chart.on("mouseout", handleMouseOut);
const cellRects = new Map<string, Rect>();
let drawFrame = 0;
let lastAvailableWidth = -1;
let lastCanvasHeight = -1;
let lastLayout: typeof layout = null;
const draw = () => {
const currentLayout = layoutRef.current;
if (!currentLayout) return;
const availableWidth = Math.floor(scroller.getBoundingClientRect().width);
if (availableWidth <= 0 || (availableWidth === lastAvailableWidth && currentLayout === lastLayout)) return;
const gapsWidth = (currentLayout.columnCount - 1) * CALENDAR_CONFIG.cellGap;
const cellWidth = Math.max(0, (availableWidth - gapsWidth) / currentLayout.columnCount);
const cellHeight = cellWidth / CALENDAR_CONFIG.cellAspectRatio;
const width = availableWidth;
const height = CALENDAR_CONFIG.rowCount * cellHeight
+ (CALENDAR_CONFIG.rowCount - 1) * CALENDAR_CONFIG.cellGap;
let lastLabelEnd = -Infinity;
const nextAxisLabels = currentLayout.monthTicks.flatMap((tick) => {
const left = Math.min(
cellOffset(tick.column, cellWidth),
availableWidth - CALENDAR_CONFIG.axisLabelWidth,
);
if (left < lastLabelEnd + CALENDAR_CONFIG.axisLabelGap) return [];
lastLabelEnd = left + CALENDAR_CONFIG.axisLabelWidth;
return [{ ...tick, left }];
});
setAxisLabels(nextAxisLabels);
if (availableWidth !== lastAvailableWidth || height !== lastCanvasHeight) {
node.style.width = "100%";
node.style.height = `${height}px`;
chart.resize({ width, height });
lastAvailableWidth = availableWidth;
lastCanvasHeight = height;
}
lastLayout = currentLayout;
const currentDates = new Set(currentLayout.cells.map((cell) => cell.date));
for (const [date, rect] of cellRects) {
if (currentDates.has(date)) continue;
chart.remove(rect);
cellRects.delete(date);
}
for (const cell of currentLayout.cells) {
const x = cellOffset(cell.column, cellWidth);
const y = cellOffset(cell.row, cellHeight);
const shape = {
x,
y,
width: cellWidth,
height: cellHeight,
r: Math.min(3, Math.min(cellWidth, cellHeight) / 4),
};
const extra = {
...cell,
kind: "calendar-cell" as const,
x,
y,
width: cellWidth,
height: cellHeight,
} satisfies CellExtra;
const current = cellRects.get(cell.date);
if (current) {
current.extra = extra;
current.stopAnimation();
current.animateTo(
{ shape, style: { fill: levelColors[cell.level] } },
{ duration: CALENDAR_CONFIG.resizeTransitionMs, easing: "cubicOut" },
);
continue;
}
const rect = new Rect({
shape,
style: {
fill: levelColors[cell.level],
stroke: "rgba(139, 148, 158, 0.10)",
lineWidth: 1,
},
cursor: "default",
extra,
});
cellRects.set(cell.date, rect);
chart.add(rect);
}
setTooltip(null);
};
const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame);
drawFrame = window.requestAnimationFrame(draw);
};
scheduleDrawRef.current = scheduleDraw;
const observer = new ResizeObserver(scheduleDraw);
observer.observe(scroller);
scheduleDraw();
return () => {
observer.disconnect();
window.cancelAnimationFrame(drawFrame);
scheduleDrawRef.current = () => undefined;
chart.dispose();
};
}, [layout !== null]);
useLayoutEffect(() => {
scheduleDrawRef.current();
}, [layout]);
if (!layout) return null;
return (
<section className={styles.root} aria-label={t("过去一年的 Token 用量")}>
<div ref={scrollerRef} className={styles.scroller}>
<div
ref={canvasRef}
className={styles.canvas}
role="img"
aria-label={t("过去一年的 Token 用量日历")}
/>
<div className={styles.axis} aria-hidden="true">
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
</div>
</div>
<Tooltip anchor={tooltip?.anchor ?? null}>
<div className={styles.tooltipContent}>
<strong>{tooltip?.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
</div>
</Tooltip>
</section>
);
}
@@ -0,0 +1,7 @@
@use "../../styles/typography" as type;
.root {
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
width: 100%;
height: 200px;
}
@@ -0,0 +1,204 @@
import type { EChartsCoreOption } from "echarts/core";
import { useMemo, useState } from "react";
import type { TokenUsageGranularity } from "../../api";
import { formatCompactInteger } from "../../utils/numberFormat";
import { EChart } from "./EChart";
import styles from "./DailyTokenUsageChart.module.scss";
export type DailyTokenUsage = {
bucketStartMs: number;
inputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
outputTokens: number;
};
type TooltipItem = {
dataIndex: number;
};
const seriesColors = {
input: "#0091ff",
cacheRead: "#40c463",
cacheWrite: "#3A62BA",
output: "#E7B40B",
} as const;
const levelLineColor = "#E7B40B";
const emptyBarColor = "rgba(139, 148, 158, 0.20)";
const EMPTY_BAR_RATIO = 1;
const DATA_HEIGHT_RATIO = 1;
const seriesFocus = {
emphasis: { focus: "series" },
blur: { itemStyle: { opacity: 0.2 } },
} as const;
function colorMark(color: string): string {
return `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background-color:${color};vertical-align:middle"></span>`;
}
function formatDay(value: Date) {
return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`;
}
function pad(value: number) {
return String(value).padStart(2, "0");
}
function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) {
const value = new Date(bucketStartMs);
if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
if (granularity === "hour") return `${pad(value.getHours())}:00`;
const day = value.getUTCDay();
if (day === 6) return t("周六");
if (day === 0) return t("周日");
return formatDay(value);
}
function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) {
const value = new Date(bucketStartMs);
if (granularity === "day") return formatDay(value);
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
return `${value.getMonth() + 1}/${value.getDate()} ${time}`;
}
function totalTokens(day: DailyTokenUsage) {
return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens;
}
export function DailyTokenUsageChart({
data,
granularity,
}: {
data: DailyTokenUsage[];
granularity: TokenUsageGranularity;
}) {
const [hovered, setHovered] = useState(false);
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
const nonZeroTotals = data.map(totalTokens).filter((total) => total !== 0);
const averageLevel = nonZeroTotals.length === 0
? 0
: nonZeroTotals.reduce((sum, total) => sum + total, 0) / nonZeroTotals.length;
const option = useMemo<EChartsCoreOption>(() => ({
animationDuration: 450,
animationEasing: "cubicOut",
grid: { top: 8, right: 0, bottom: 0, left: 0 },
tooltip: {
trigger: "axis",
confine: true,
backgroundColor: "var(--vscode-editorHoverWidget-background)",
borderColor: "var(--vscode-editorHoverWidget-border)",
textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" },
extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;",
axisPointer: {
type: "shadow",
shadowStyle: { color: "rgba(139, 148, 158, 0.14)" },
},
formatter: (params: unknown) => {
const first = (params as TooltipItem[])[0];
const day = data[first.dataIndex];
return [
formatTooltipTime(day.bucketStartMs, granularity),
`${t("总请求")}:${formatCompactInteger(totalTokens(day))}`,
`${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`,
`${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`,
`${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`,
`${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`,
].join("<br/>");
},
},
xAxis: {
type: "category",
data: data.map(({ bucketStartMs }) => bucketStartMs),
axisTick: { show: false },
axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } },
axisLabel: {
interval: "auto",
hideOverlap: true,
formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity),
color: "#8c8c8c",
fontFamily: "HFKos",
margin: 14,
},
},
yAxis: {
type: "value",
show: false,
min: 0,
max: axisMaximum,
},
series: [
{
name: t("无用量"),
type: "bar",
stack: "empty-placeholder",
data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0),
barMaxWidth: 18,
silent: true,
z: 0,
itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] },
emphasis: { disabled: true },
},
{
name: t("输入(非缓存)"),
type: "bar",
stack: "tokens",
data: data.map(({ inputTokens }) => inputTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.input },
...seriesFocus,
},
{
name: t("缓存输入"),
type: "bar",
stack: "tokens",
data: data.map(({ cacheReadTokens }) => cacheReadTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.cacheRead },
...seriesFocus,
},
{
name: t("缓存写入"),
type: "bar",
stack: "tokens",
data: data.map(({ cacheWriteTokens }) => cacheWriteTokens),
barMaxWidth: 18,
itemStyle: { color: seriesColors.cacheWrite },
...seriesFocus,
},
{
name: t("模型输出"),
type: "bar",
stack: "tokens",
data: data.map(({ outputTokens }) => outputTokens),
barMaxWidth: 18,
barGap: "-100%",
itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] },
markLine: {
silent: true,
symbol: "none",
lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 },
label: {
show: hovered,
position: "insideStartTop",
formatter: t("平均"),
color: levelLineColor,
distance: 8,
},
data: [{ yAxis: averageLevel }],
},
...seriesFocus,
},
],
}), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]);
return <EChart
option={option}
className={styles.root}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>;
}
@@ -0,0 +1,4 @@
.root {
width: 100%;
height: 260px;
}
@@ -0,0 +1,38 @@
import { BarChart, LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { useEffect, useRef, type MouseEventHandler } from "react";
import styles from "./EChart.module.scss";
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
type EChartProps = {
option: EChartsCoreOption;
className?: string;
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
};
export function EChart({ option, className = styles.root, onMouseEnter, onMouseLeave }: EChartProps) {
const element = useRef<HTMLDivElement>(null);
useEffect(() => {
const node = element.current;
if (!node) return;
const chart = init(node, undefined, { renderer: "canvas" });
const observer = new ResizeObserver(() => chart.resize());
observer.observe(node);
return () => {
observer.disconnect();
chart.dispose();
};
}, []);
useEffect(() => {
const node = element.current;
if (node) getInstanceByDom(node)?.setOption(option, { notMerge: true });
}, [option]);
return <div ref={element} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />;
}
@@ -0,0 +1,21 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../api";
import { EChart } from "./EChart";
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
const points = calls.slice(0, 20).reverse();
const option: EChartsCoreOption = {
animationDuration: 280,
color: ["#d79a62", "#ad84cf"],
grid: { top: 22, right: 18, bottom: 30, left: 48 },
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
series: [
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
],
};
return <EChart option={option} />;
}
@@ -0,0 +1,21 @@
import type { EChartsCoreOption } from "echarts/core";
import type { LlmCall } from "../../api";
import { EChart } from "./EChart";
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
const points = calls.slice(0, 20).reverse();
const option: EChartsCoreOption = {
animationDuration: 280,
color: ["#6f91f4", "#62c6a3"],
grid: { top: 22, right: 18, bottom: 30, left: 48 },
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
yAxis: { type: "value", axisLabel: { color: "#888" }, splitLine: { lineStyle: { color: "#8882" } } },
series: [
{ name: t("输入 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.input_tokens ?? 0), barMaxWidth: 22 },
{ name: t("输出 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.output_tokens ?? 0), barMaxWidth: 22 },
],
};
return <EChart option={option} />;
}
@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from "react";
import { useAppStore } from "../../store/appStore";
import controls from "../ui/Controls.module.scss";
import styles from "./CursorSettings.module.scss";
const CaReady = createContext(false);
const ModelsReady = createContext(false);
export function CursorCaProvider({ children }: { children: ReactNode }) {
const { cursorHarness } = useAppStore();
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
}
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
const ready = useContext(CaReady);
const { cursorHarness } = useAppStore();
if (ready) return children;
const unsupported = cursorHarness?.ca === "unsupported";
const installedLocally = cursorHarness?.ca === "untrusted";
return <div className={styles.gate}>
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
</div>;
}
export function CursorModelProvider({ children }: { children: ReactNode }) {
const { models } = useAppStore();
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
}
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
const ready = useContext(ModelsReady);
if (ready) return children;
return <div className={styles.gate}>
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
</div>;
}
@@ -0,0 +1,98 @@
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
import { FormField, TextInput } from "../ui/FormControls";
import { Checkbox } from "../ui/Checkbox";
import { JsonEditor } from "../ui/JsonEditor";
import { Combobox, MultiCombobox, Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import controls from "../ui/Controls.module.scss";
import { TooltipTrigger } from "../ui/TooltipTrigger";
import { claudeIcon, openAiIcon } from "../ui/icons";
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
import styles from "./CursorSettings.module.scss";
export type CursorModelDraft = {
providerMode: string;
provider: ProviderInput;
model: ModelInput;
modelIds: string[];
headersText: string;
extraText: string;
customRequestUrl: boolean;
};
export const emptyCursorModelDraft = (): CursorModelDraft => ({
providerMode: "new",
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
modelIds: [],
headersText: defaultCustomHeadersText,
extraText: "{}",
customRequestUrl: false,
});
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
draft: CursorModelDraft;
providers: Provider[];
editing: boolean;
modelOptions: string[];
discovering: boolean;
onChange: (draft: CursorModelDraft) => void;
onDiscover: () => void;
}) {
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
const canDiscover = draft.providerMode !== "new"
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
const selectProvider = (providerMode: string) => {
const endpointType = providerMode === "new"
? draft.provider.provider_type
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
};
const setEndpointType = (endpoint_type: ProviderType) => onChange({
...draft,
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
model: { ...draft.model, endpoint_type },
});
const setModelIds = (modelIds: string[]) => onChange({
...draft,
modelIds,
model: {
...draft.model,
model_id: modelIds[0] ?? "",
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
},
});
return <div className={styles.editor}>
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
{ value: "new", label: t("新建上游") },
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
]} onChange={selectProvider} /></FormField>}
<div className={styles.grid}>
{!editing && draft.providerMode === "new" && <>
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><TextInput type="password" placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
</>}
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
}</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
</>}
<div className={`${styles.switches} ${styles.fullWidth}`}>
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
</div>
</div>
</div>;
}
@@ -0,0 +1,135 @@
@use "../../styles/typography" as type;
.page {
display: grid;
gap: 16px;
}
.gate {
min-height: 250px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--vscode-descriptionForeground);
text-align: center;
border: 1px dashed var(--vscode-sideBar-border);
border-radius: var(--oa-overlay-radius);
strong {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
max-width: 440px;
font-size: type.$font-size-xs;
}
button {
margin-top: 6px;
}
}
.groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.providerTitle {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 3px;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.models {
display: flex;
flex-direction: column;
}
.modelRow {
min-height: 54px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 8px 14px;
// border-top: 1px solid var(--vscode-sideBar-border);
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.badge {
padding: 2px 7px;
color: var(--vscode-badge-foreground);
background: var(--vscode-badge-background);
border-radius: 999px;
font-size: type.$font-size-2xs;
}
.rowActions {
display: flex;
gap: 2px;
}
.editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.command {
margin: 0;
padding: 10px;
overflow: auto;
color: var(--vscode-textLink-foreground);
background: var(--vscode-textCodeBlock-background);
border-radius: 4px;
white-space: pre-wrap;
user-select: text;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.fullWidth {
width: 100%;
grid-column: 1 / -1;
}
.switches {
display: flex;
flex-direction: column;
gap: 9px;
label {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
.fullWidth {
grid-column: auto;
}
}
@@ -0,0 +1,51 @@
@use "../../styles/typography" as type;
.title {
min-width: 0;
display: flex;
align-items: center;
gap: 5px;
}
.row {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
& + & {
border-top: 1px solid var(--vscode-sideBar-border);
}
}
.description {
min-width: 0;
display: grid;
gap: 5px;
small {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.selectControl,
.addressInput {
width: min(300px, 44%);
flex: 0 0 auto;
}
@media (max-width: 620px) {
.row {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.selectControl,
.addressInput {
width: 100%;
}
}
@@ -0,0 +1,53 @@
import cursorIconUrl from "../../assets/icons/cursor.svg";
import type { TabMode, TabSettings } from "../../api";
import { Button } from "../ui/Button";
import { TextInput } from "../ui/FormControls";
import { Icon } from "../ui/Icon";
import { Select } from "../ui/Select";
import { TitledCard } from "../ui/TitledCard";
import styles from "./TabSettingsCard.module.scss";
export function TabSettingsCard({ settings, saving, onChange, onSave }: {
settings: TabSettings;
saving: boolean;
onChange: (settings: TabSettings) => void;
onSave: () => void;
}) {
return <TitledCard
title={<div className={styles.title}><Icon src={cursorIconUrl} size="1.1em" /><span>{t("TAB 设置")}</span></div>}
action={<Button size="small" variant="primary" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>}
>
<div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 选择")}</strong>
<small>{t("控制 Cursor TAB 相关接口的连接方式。")}</small>
</div>
<div className={styles.selectControl}>
<Select
value={settings.mode}
ariaLabel={t("TAB 选择")}
options={[
{ value: "public", label: t("使用公益服务") },
{ value: "direct", label: t("直连") },
{ value: "custom", label: t("自定义") },
]}
onChange={(mode) => onChange({ ...settings, mode: mode as TabMode })}
/>
</div>
</div>
{settings.mode === "custom" && <div className={styles.row}>
<div className={styles.description}>
<strong>{t("TAB 服务地址")}</strong>
<small>{t("原接口路径会追加到此服务地址。")}</small>
</div>
<TextInput
className={styles.addressInput}
value={settings.address}
placeholder="https://tab.leokun.cn"
aria-label={t("TAB 服务地址")}
onChange={(event) => onChange({ ...settings, address: event.target.value })}
onKeyDown={(event) => { if (event.key === "Enter") onSave(); }}
/>
</div>}
</TitledCard>;
}
@@ -0,0 +1,49 @@
@use "../../styles/typography" as type;
.root {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.title {
position: absolute;
z-index: 21;
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-app-region: drag;
}
.fixedContent {
width: calc(100% - var(--app-page-padding) * 2);
max-width: var(--app-page-max-width);
padding-top: var(--app-content-top);
margin-right: auto;
margin-left: auto;
}
.fixedContent,
.fixedSection {
height: 100%;
min-width: 0;
min-height: 0;
}
.fixedSection {
width: 100%;
}
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import type { VirtualPageSection } from "./VirtualPage";
import { VirtualPage } from "./VirtualPage";
import styles from "./PageContent.module.scss";
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
return <div className={styles.root}>
{fixed && title != null && <div className={styles.title}>{title}</div>}
{fixed
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
</div>;
}
@@ -0,0 +1,22 @@
.root {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header,
.footer {
flex: 0 0 auto;
min-width: 0;
}
.body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
@@ -0,0 +1,18 @@
import type { ElementType, ReactNode } from "react";
import styles from "./PageLayout.module.scss";
type PageLayoutProps = {
as?: ElementType;
className?: string;
header?: ReactNode;
footer?: ReactNode;
children: ReactNode;
};
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
{header && <div className={styles.header}>{header}</div>}
<div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>}
</Component>;
}
@@ -0,0 +1,33 @@
@use "../../styles/typography" as type;
.root {
width: 100%;
height: 100%;
min-height: 0;
}
.content {
width: calc(100% - var(--app-page-padding) * 2);
max-width: var(--app-page-max-width);
padding-top: var(--app-content-top);
padding-bottom: var(--app-page-padding);
margin-right: auto;
margin-left: auto;
}
.section {
position: relative;
width: 100%;
}
.title {
position: absolute;
z-index: 21;
top: 0;
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -0,0 +1,31 @@
import { useCallback, type ReactNode } from "react";
import { VirtualList } from "../virtual/VirtualListEngine";
import styles from "./VirtualPage.module.scss";
export type VirtualPageSection = {
key: string;
estimatedHeight: number;
content: ReactNode;
};
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
{section.content}
</section>, [title]);
return <VirtualList
items={sections}
getKey={getKey}
estimateSize={estimateSize}
renderItem={renderItem}
overscan={2}
itemGap={16}
scrollbarSize={7}
scrollbarInsetTop="var(--app-content-top)"
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
/>;
}
@@ -0,0 +1,30 @@
@use "../../styles/typography" as type;
.root {
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
position: relative;
width: 132px;
height: 82px;
align-self: center;
flex: 0 0 auto;
}
.canvas {
width: 100% !important;
height: 100% !important;
}
.label {
position: absolute;
right: 0;
bottom: 10px;
left: 0;
display: flex;
justify-content: center;
color: var(--vscode-foreground);
font-family: var(--oa-number-font);
font-size: type.$font-size-xl;
line-height: 1;
pointer-events: none;
}
@@ -0,0 +1,75 @@
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
import { useMemo } from "react";
import { Doughnut } from "react-chartjs-2";
import styles from "./CacheHitRateChart.module.scss";
ChartJS.register(ArcElement, Tooltip);
type SegmentRadius = number | {
outerStart: number;
outerEnd: number;
innerStart: number;
innerEnd: number;
};
function chartColor(context: ScriptableContext<"doughnut">) {
const styles = getComputedStyle(context.chart.canvas);
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
return styles.getPropertyValue(variable).trim();
}
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
const radius = 5;
if (percentage <= 0) {
return dataIndex === 1
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
: 0;
}
if (percentage >= 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 options: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: { duration: 450 },
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
};
export function CacheHitRateChart({ rate }: { rate: number }) {
const finiteRate = Number.isFinite(rate) ? rate : 0;
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
const data = useMemo(() => ({
labels: [t("命中"), t("未命中")],
datasets: [{
data: [percentage, Math.max(0, 100 - percentage)],
backgroundColor: chartColor,
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
}],
}), [percentage]);
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
<Doughnut className={styles.canvas} data={data} options={options} />
<div className={styles.label}>{label}</div>
</div>;
}
@@ -0,0 +1,86 @@
@use "../../styles/typography" as type;
.scroller {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.root {
width: 100%;
height: 130px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--vscode-sideBar-border);
border-radius: var(--oa-overlay-radius);
font-family: var(--oa-metrics-font);
}
.metric {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
& + & {
border-left: 1px solid var(--vscode-sideBar-border);
}
}
.label {
display: flex;
align-items: center;
gap: 5px;
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
line-height: 1;
}
.info {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
transition: color 150ms ease;
opacity: 0.5;
&:hover,
&:focus-visible {
color: var(--vscode-foreground);
}
}
.body {
min-width: 0;
}
.value {
overflow: hidden;
color: var(--vscode-foreground);
font-family: var(--oa-number-font);
font-size: type.$font-size-xxl;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary {
margin-top: 12px;
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltipText {
white-space: pre-wrap;
}

Some files were not shown because too many files have changed in this diff Show More