Compare commits

..
Author SHA1 Message Date
leookun 2f47f02497 chore(cursor-proxy-debugger): enhance traffic capture for Fork Chat and update README
- Added support for capturing and decoding Fork Chat traffic, including `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs`.
- Updated the README to reflect new features and usage instructions for Fork Chat traffic.
- Modified `.gitignore` to include `proto/extensions-cursor-app/`.
- Refactored `Taskfile.yml` to improve error messages related to Cursor extensions.
- Introduced new tests for decoding functionality in `cursor-proxy-debugger`.
2026-08-05 01:17:01 +08:00
leookun 058aaa532e remote: cursor source code from git 2026-08-05 00:34:30 +08:00
leookun 622a57fed7 Revert "Merge pull request #239 from DedSecer/fix/cursor-fork-context"
This reverts commit 697fa99245, reversing
changes made to 5742073be5.
2026-08-04 23:49:49 +08:00
leokunandGitHub 639c452a00 Merge pull request #252 from leookun/release/0.0.44
chore(release): update version to 0.0.44 and enhance release notes
2026-08-03 23:18:11 +08:00
leookun 0c9a07e018 chore(release): update version to 0.0.44 and enhance release notes
- Updated version number to 0.0.44 across all relevant files.
- Added support for cursor-cli in release notes.
2026-08-03 23:17:44 +08:00
leokunandGitHub 1adca7f600 Merge pull request #241 from warelik/fix/cli-local-mode-endpoints
fix(backend): cover cursor-agent CLI local-mode endpoint surface — startup 404s, git-workspace tool 404s, telemetry
2026-08-03 22:58:47 +08:00
leookun 5df3e8f512 feat(cursor): update model handling to use agentv1 responses and enhance CLI model details
- Refactored response handling in newProtoMessage to utilize agentv1 for GetUsableModelsResponse and GetDefaultModelForCliResponse.
- Added new tests for buildCLIModelDetails and encodeCLIModels to ensure correct model details and encoding.
- Updated buildUsableModelsPayload and buildDefaultModelForCliPayload to leverage buildCLIModelDetails for improved model data structure.
2026-08-03 22:56:17 +08:00
leokunandGitHub a2e3a07773 Update README.md 2026-08-03 18:54:56 +08:00
leokunandGitHub 6b7c077448 Update README.md 2026-08-03 18:54:04 +08:00
leokunandGitHub c64911e8c6 Update README.md 2026-08-03 18:53:30 +08:00
leokunandGitHub d1d56bd6eb 更新 README.md 2026-08-03 12:31:41 +08:00
leokunandGitHub ee47f187f0 Merge pull request #249 from leookun/release/0.0.43
chore(release): update version to 0.0.43 and enhance release notes
2026-08-03 01:23:21 +08:00
leookun 726f082e6b chore(release): update version to 0.0.43 and enhance release notes
- Updated version number to 0.0.43 across all relevant files.
- Added new features and fixes to release notes, including:
  - Fix for command recognition issues.
  - Fix for summarize command.
  - Fix for MiniMax thinking restriction.
  - Support for Fork message and conversation linking.
  - Introduction of cursor debugger.
  - Added QQ and Telegram group information.
2026-08-03 01:22:56 +08:00
leookun 424235f11d Merge branch 'main' into fix/cli-local-mode-endpoints
# Conflicts:
#	internal/backend/server/middleware.go
2026-08-03 00:39:56 +08:00
leokunandGitHub 58770aa206 Merge pull request #231 from octo-patch/octo/20260730-parameter-refresh-recvqgk2Qimxgy
fix(openai): recognize MiniMax in thinking disable
2026-08-03 00:35:29 +08:00
leokunandGitHub eb0ad7b636 Merge pull request #248 from leookun/codex/cursor-proxy-debugger
feat: add Cursor protocol debugger
2026-08-03 00:23:40 +08:00
leokunandGitHub 697fa99245 Merge pull request #239 from DedSecer/fix/cursor-fork-context
fix(forwarder): preserve Cursor fork context
2026-08-03 00:21:14 +08:00
leokun 1854a3ab9a feat(cursor): add independent Cursor protocol debugging proxy and build tasks
- Introduced a new task for running the Cursor protocol debugging proxy.
- Added a build task for the Cursor protocol debugging proxy.
- Updated documentation to include usage instructions for the debugging tool.
2026-08-03 00:19:18 +08:00
leokunandGitHub 5742073be5 Merge pull request #242 from DedSecer/feat/cursor-local-history-transcripts
feat(cursor): support referencing local history from new chats
2026-08-02 23:27:31 +08:00
leookun 90d35085c0 Merge branch 'main' into feat/cursor-local-history-transcripts 2026-08-02 22:58:50 +08:00
leokunandGitHub 7764eca0c3 Merge pull request #243 from aike0210/codex/feat-cursor-control-plane-account
feat(cursor): 修复本地模式下插件市场、Skills 和 MCP 无法加载的问题
2026-08-02 22:56:53 +08:00
leookun a5437e60fe feat(cursor): add contributor profile link and improve UI for Cursor account card
- Integrated a tooltip with a button to open the contributor's GitHub profile in the CursorAccountCard component.
- Updated the layout of the account card for better visual organization.
- Removed routing mode options from the configuration view and adjusted related translations in multiple languages.
- Cleaned up unused routing mode logic in the app state management.
2026-08-02 22:53:57 +08:00
leookun 674de0b041 Merge branch 'main' into codex/feat-cursor-control-plane-account 2026-08-02 22:28:56 +08:00
leookun d1ffd1a95c feat: delete test requirements 2026-08-02 21:39:47 +08:00
aike1202 9d316c0b3d feat(cursor): 支持独立控制面账号登录 2026-08-01 22:05:08 +08:00
DedSecer 834288839b test(cursor): make transcript adapter tests self-contained
Keep the transcript coverage independent from branch-specific projector helpers so it runs cleanly on the upstream main test layout.
2026-08-01 21:10:46 +08:00
DedSecer 68e63c7a72 feat(cursor): sync local conversation history to Cursor transcripts
Project sidecar context history into Cursor-compatible JSONL transcripts so previous conversations remain readable when referenced from new chats. Backfill existing sessions on startup and preserve Cursor-managed turn status entries during atomic updates.
2026-08-01 21:08:14 +08:00
warelik 2c650b3765 fix(backend): cover cursor-agent CLI local-mode endpoint surface
Desktop Cursor works through the local proxy because it drives the agent
over BidiAppend/RunSSE plus the already-mocked unary endpoints. The
cursor-agent CLI speaks the same agent protocol but calls additional
unary endpoints that had no local handlers, so every request fell into a
wildcard route and came back as HTTP 404, which the Connect client maps
to '[unimplemented] HTTP 404'.

Three independent breaks, one visible symptom:

1. Startup: ServerConfigService/GetServerConfig (only the AiService
   variant was mocked), DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam
   and DashboardService/ListMarketplaces were missing, so the CLI aborted
   during session init.

2. Git workspaces: the CLI resolves the repo path-encryption key from
   indexingConfig.default{User,Team}PathEncryptionKey in GetServerConfig
   when no IDE-stored repo keys exist. The mock returned no indexingConfig,
   so repository identity init failed with 'No encryption key found'.

3. Tool execution: every fs tool executor (Ls/Grep/Glob/Shell) consults
   the ignore service, which calls getRepoBlockExcludeGlobs() ->
   DashboardService/GetTeamReposOrEmptyIfNotInTeam. The 404 propagated as
   the tool result error, so model answers arrived but every tool call
   returned '[unimplemented] HTTP 404'. Non-git workspaces skip the
   repo-block path, which is why tools only failed inside git repos.

Also close the remaining tolerated 404 noise so a CLI session produces
zero unimplemented responses: model listing (GetUsableModels,
GetDefaultModelForCli, GetDefaultModel), dashboard/plugin housekeeping
(GetGlobalCommands, GetEffectiveUserPlugins, RegisterMarketplaceAndPlugins,
GetCliDownloadUrl) and telemetry (AnalyticsService/SubmitLogs,
AnalyticsService/TrackEvents, OTLP /v1/traces).

Additional logging: PolicyMiddleware now includes the request path in the
per-request log line, which is what made this diagnosable from app.log.
2026-08-01 10:58:19 +03:00
DedSecerandCursor 14b286a466 fix(forwarder): preserve Cursor fork context
Store checkpoint turns as validated blobs and restore prefetched parent turns so forked conversations retain their replay history and rewind prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 10:51:23 +08:00
octo-patch 828e0a9941 fix(openai): recognize MiniMax in thinking disable
Route the MiniMax OpenAI-compatible endpoint (global api.minimax.io and
China api.minimaxi.com hosts, plus the MiniMax-M3/MiniMax-M2.7 model ids)
to the thinking_type disable branch so that disabling thinking writes
thinking:{type:"disabled"} and drops reasoning_effort, matching the
existing MiMo/deepseek/glm behavior.
2026-07-30 13:25:22 +00:00
leokunandGitHub 00f52166b2 Merge pull request #222 from DedSecer/fix/cursor-command-prompt-replay
fix(prompt): replay selected Cursor commands
2026-07-30 20:30:01 +08:00
leokun 3349b13a2b fix(prompt): persist cursor command replay context 2026-07-30 20:28:20 +08:00
DedSecerandCursor 4451a6df3b fix(forwarder): handle Cursor summarize actions
Map Cursor's protocol-level SummarizeAction to manual context compaction while keeping the legacy /compact directive disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 19:32:42 +08:00
leokunandGitHub 7b1c1d39af Update README.md 2026-07-28 22:33:49 +08:00
DedSecer 17f30a8852 fix(prompt): replay selected Cursor commands
Include client-resolved command content in model-visible user history so slash commands such as /init retain their actual instructions.
2026-07-28 10:46:07 +08:00
leookun 1f976b2a5e release: 0.0.42
- 修复grep或者read长时间阻塞问题 @liorxuan
2026-07-27 21:57:51 +08:00
leokunandGitHub f3a7aece9d Merge pull request #221 from qqlzyhello/fix/bidi-append-seq-restart
同 request_id 下 append_seq 重启时不再误判 stale
2026-07-27 21:53:04 +08:00
lixingcheng 3a3be0c9e9 fix(forwarder): reset bidi append seq when Cursor reuses request_id
Cursor may reuse the same request_id for a later turn and restart
append_seqno from 1. Accept seq=1 after progress as a sequence restart
when idle so tool results are not discarded as stale forever.
2026-07-27 18:09:09 +08:00
leookun 9e05739969 docs: 拆分贡献指南为中英双语独立文件
CONTRIBUTING.md(中文)+ CONTRIBUTING_EN.md(英文),互相链接。
2026-07-27 01:02:21 +08:00
leookun 4bb0cbc1fc docs: 添加双语贡献指南和 PR 模板
Closes #161

- CONTRIBUTING.md:中英双语,涵盖开发环境、项目结构、代码规范、构建发布
- .github/PULL_REQUEST_TEMPLATE.md:双语 PR 描述模板与检查清单
2026-07-27 01:01:29 +08:00
leookun 0f6b0f9484 release: 0.0.41
- 修复内存泄漏问题,该可能导致内存异常占用
- 支持俄语增加翻译范围
- 修复qwen-3.8-max中断问题(mimo也应该属于同一类问题)
- 修复claude模型可能无法识别图片问题 @GGHansome
- 支持自定义Openai端点 @Sxuan-Coder
- WebSearch 接入百度搜索,DuckDuckGo 作为兜底 @杨超
- 修复一些兼容性问题 @kael-odin
- 修复window上一些表现问题 @philau2512

🔔 如何让AI自动拉模型配置? (以下为提示词,把地址和密钥换为你的)
我的模型配置在 ~/.cursor-local-assistant-v2/config.yaml,我的API地址是:https://xxx 密钥是xxx,帮我拉所有模型配置进去,不要影响已有模型,根据models标准接口拉取。
2026-07-27 00:55:02 +08:00
leookun e401303f64 fix: 修复502导致的内存泄漏问题 2026-07-27 00:15:23 +08:00
leokunandGitHub 799dbda7e0 Merge pull request #178 from kael-odin/fix/mimo-thinking-disable
fix(openai): recognize MiMo in thinking disable
2026-07-16 10:39:50 +08:00
leokunandGitHub f3cbf1e6c3 Merge pull request #179 from kael-odin/fix/anthropic-thinking-disable
fix(anthropic): apply thinking config on override path, symmetric with openai
2026-07-16 10:39:19 +08:00
leokunandGitHub 2380a0d692 Merge pull request #187 from ProtectCookies/main
feat: WebSearch 接入百度搜索,DuckDuckGo 作为兜底
2026-07-16 10:36:01 +08:00
leokunandGitHub 1da1faa4d4 Merge pull request #186 from GGHansome/main
尝试修复使用claude模型无法识别图片问题
2026-07-16 10:35:43 +08:00
leokunandGitHub ce05b0bf7a Merge pull request #205 from philau2512/main
fix(ui): support window resizing and fully localize remaining Chinese texts to EN/JA
2026-07-16 10:35:14 +08:00
philau2512 44a47992d9 feat(build): add PowerShell and batch build scripts for convenient compilation 2026-07-15 17:53:45 +07:00
philau2512 319bd3d7d8 fix(i18n): extract new Chinese keys and implement dynamic localization for footer/updates/tray menu 2026-07-15 17:53:42 +07:00
philau2512 12c7973a06 fix(window): enable maximize button state to support native window resizing on Windows 2026-07-15 17:53:38 +07:00
leokunandGitHub f93b18740d Merge pull request #189 from leookun/release/0.0.40
release: 0.0.40
2026-07-10 11:52:27 +08:00
leokun c6ee26b12d release: 0.0.40 2026-07-10 11:50:02 +08:00
leokunandGitHub 7c1c6a134b Merge pull request #188 from leookun/feat/gpt-5.6
feat: gpt-5.6 support
2026-07-10 11:42:10 +08:00
leokun e0aa270328 feat: gpt-5.6 support 2026-07-10 11:38:45 +08:00
杨超 556ea21f38 feat: WebSearch 接入百度搜索,DuckDuckGo 作为兜底
百度搜索失败或无结果时自动回退到 DuckDuckGo,不引入 Bing。
2026-07-10 10:36:59 +08:00
GGHansome 315aba49c2 fix: claude model can not access image 2026-07-10 03:17:02 +08:00
leokunandGitHub 6ab537ad4e Merge pull request #180 from kael-odin/docs/extra-params-tooltip-wording
docs(model-adapter): fix extra-params tooltip wording (merge→override)
2026-07-09 20:26:49 +08:00
kael-odin 6d7a4e32fe docs(model-adapter): fix extra-params tooltip wording (merge→override)
ModelAdapterModal.vue described OpenAI extra params as "合并到" (merge into)
the request body, but the backend (request_override.go:applyExtraParams)
writes body[name] = value — same-name fields are overridden, not merged.
This contradicted ModelEditor.vue's tooltip ("覆盖到...同名字段以这里为准")
and risked users expecting array fields like `tools` to be appended,
which would silently replace the built-in function tools.

Align the modal tooltip with the editor view and the actual override
semantics.
2026-07-08 09:30:37 +08:00
kael-odin 50ab63de3d fix(anthropic): apply thinking config on override path, symmetric with openai
AnthropicAdapter.Stream built thinking config (buildAnthropicThinkingConfig)
and wrote it into body only inside the `if len(body)==0` normal-construction
block. The RequestBodyOverride branch skipped it entirely — a disabled effort
on the override path left whatever thinking config the override body carried,
violating user intent and diverging from openai.go, where
applyOpenAIThinkingDisable runs unconditionally after both branches.

Also, on the normal path, disabled only wrote thinking:{type:disabled} but
left a stale output_config (set by a prior adaptive turn or by
AnthropicExtraParams) in place — an explicitly disabled request could still
carry output_config.effort=high, a contradictory payload.

Fix:
- Extract applyAnthropicThinkingConfig(body, req), called unconditionally
  after the override/normal block, mirroring openai.go:1860.
- disabled: force thinking:{type:"disabled"}, delete output_config, set
  thinking_disabled_provider_param=thinking.type knob.
- adaptive (AnthropicThinkingEffort non-empty): write
  thinking:{type:adaptive,display:summarized} + output_config.
- empty effort: no-op.
- buildAnthropicThinkingConfig retained for stable-message-count / message
  normalization signals inside the normal block.

Tests: anthropic_thinking_disable_test.go covers all three branches, alias
normalization (off→disabled), disabled overriding existing adaptive config,
and the override-path symmetry scenario.
2026-07-08 09:12:03 +08:00
kael-odin 802daa8fcd fix(openai): recognize MiMo in thinking disable
openAIThinkingDisableKind only matched dashscope/qwen/aliyun,
deepseek/bigmodel/z.ai/zhipu, and gpt-5.1+/gpt-6. Xiaomi MiMo
(api.xiaomimimo.com, model mimo-*) fell through to the default
branch, so when a user set reasoning=disabled the adapter wrote
no disable field and MiMo kept thinking enabled (extra latency +
reasoning token cost contrary to user intent).

Add xiaomimimo/mimo to the thinking_type case — MiMo uses the same
thinking:{type:"disabled"} shape as deepseek/glm, confirmed in
MiMo docs.

- openai.go: +2 conditions in openAIThinkingDisableKind
- openai_thinking_disable_test.go: cover MiMo (base/model/ultraspeed),
  regress deepseek/glm/z.ai/qwen/gpt-5.1/gpt-6/unknown, and verify
  applyOpenAIThinkingDisable writes thinking:{type:disabled} +
  deletes reasoning_effort for MiMo
2026-07-07 18:13:21 +08:00
leokunandGitHub eb2c236699 Merge pull request #168 from Sxuan-Coder/main
feat(openai): 支持"自定义路径"endpoint 以兼容非标准 OpenAI 兼容服务
2026-07-06 10:38:28 +08:00
上玄 86a655fb5d refactor: remove endpoint tests and enhance URL handling
- Deleted outdated test files for OpenAI endpoint functionality.
- Improved the OpenAIEndpointURL function to better handle custom paths and version segments.
- Introduced a new helper function, stripEndpointVersionPrefix, to streamline endpoint processing.
2026-07-04 19:03:14 +08:00
上玄 c50e7d0606 feat: add support for custom OpenAI endpoint path
- Introduced a new endpoint option for OpenAI integrations, allowing users to specify a custom path.
- Updated relevant components and validation logic to accommodate the new endpoint.
- Enhanced documentation and error messages to reflect the addition of the custom endpoint option.
2026-07-04 18:51:41 +08:00
leokun 4ec7b4289f v0.0.39 2026-07-01 15:31:24 +08:00
leokun 6bebe37bfc Merge branches 'main' and 'main' of github.com:leookun/cursor-byok 2026-07-01 14:25:30 +08:00
leokunandGitHub 90c3c35774 Merge pull request #160 from lixiangwuxian/main
feat: add support for multiple paths when using workspace mode
2026-06-30 20:08:51 +08:00
lixiangwuxian 64a0e09605 feat: add support for multiple paths when using workspace mode 2026-06-30 16:58:50 +08:00
leokun 47d336ac81 update readme 2026-06-30 10:49:26 +08:00
leokun 268e9fbde0 Add MIT License file 2026-06-30 10:46:26 +08:00
leokun 7d89b0af2a Remove obsolete build tasks from BUILD.MD 2026-06-30 10:43:29 +08:00
leokun c083be5ec2 v0.3.8 2026-06-30 10:38:52 +08:00
340 changed files with 174454 additions and 2 deletions
+344
View File
@@ -0,0 +1,344 @@
---
name: coding-guidance
description: 本地模式实现指南
---
当用户在处理本地模式的时候,使用此指南
先遵守这个约束:
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
- 需要确认当前到底是哪一个 app 副本在运行
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
- 需要对照已安装客户端行为与本仓库实现差异
本地模式协议需要优先核对这些文件:
- proto/agent_v1.proto
- proto/aiserver_v1.proto
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
- 常用格式化路径:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
## 本仓库已固定的会话承接规则
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq``next_entry_seq``context_version``current_todos``current_plans``latest_request_prefix``last_provider_call`
- `context.json.items` 保存 append-only 的语义历史 entriesprovider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hashresolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
- `summary.json``replay.json``runtime.json``request.json``conversation.json``entries.jsonl``turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json``next_entry_seq``next_turn_seq``context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
# 已确认结论
## 1. `AgentServerMessage` 不是统一都要“回复完成”
要按 `oneof message` 分类看:
- `exec_server_message`
- 这是服务端发给客户端的“执行请求”。
- 客户端需要显式回 `ExecClientMessage`
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
- `stream_close`
- `throw`
- `heartbeat`
- `interaction_query`
- 这是服务端发给客户端的“交互请求”。
- 客户端需要显式回 `InteractionResponse`
- `interaction_update`
- 这是展示/状态更新消息,通常不需要客户端回包。
- `conversation_checkpoint_update`
- 这是 checkpoint 同步消息,通常不需要客户端回包。
- `kv_server_message`
- 这是 KV 同步消息,通常不需要客户端回包。
- `exec_server_control_message`
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
`cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`
## 2.1 更具体的客户端侧结论
`cursor-always-local/dist/main.js` 里能直接确认:
- `AgentServerMessage` 的下行类型里有:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `conversation_checkpoint_update`
- `interaction_query`
- `AgentClientMessage` 的上行类型里有:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
- `exec_server_message`
-> 客户端执行本地工具
-> 产出 `exec_client_message`
-> 以及可选 `exec_client_control_message`
- `interaction_query`
-> 客户端展示或处理交互
-> 产出 `interaction_response`
- 其他下行消息
-> 一般只更新 UI / checkpoint /流状态
-> 不会自然地产生一个“完成 ack”
## 2.2 `exec_server_message` 常见的客户端回包形态
客户端协议模型里已确认这些回包类型:
- `ExecClientMessage`
- 正常结果面
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream`
- `ExecClientControlMessage`
- 控制面
- 包括:
- `stream_close`
- `throw`
- `heartbeat`
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`
- 有些工具只回一次结果面消息
- shell 之类的流式工具会混合回:
- 多次 `shell_stream`
- 以及控制消息(例如 `stream_close` / `heartbeat`
## 2.3 `exec_server_message` 的完整回包形态
事实依据:
- `proto/agent_v1.proto`
- `ExecServerMessage.oneof message`
- `ExecClientMessage.oneof message`
- `ExecClientControlMessage.oneof message`
- `cursor-always-local/dist/main.js`
- bundle 内含同名 proto 模型
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
`ExecServerMessage``message` 分支与 `ExecClientMessage``message` 分支是一一对应的:
- `shell_args`
-> `shell_result`
- `write_args`
-> `write_result`
- `delete_args`
-> `delete_result`
- `grep_args`
-> `grep_result`
- `read_args`
-> `read_result`
- `ls_args`
-> `ls_result`
- `diagnostics_args`
-> `diagnostics_result`
- `request_context_args`
-> `request_context_result`
- `mcp_args`
-> `mcp_result`
- `shell_stream_args`
-> `shell_stream`
- `background_shell_spawn_args`
-> `background_shell_spawn_result`
- `list_mcp_resources_exec_args`
-> `list_mcp_resources_exec_result`
- `read_mcp_resource_exec_args`
-> `read_mcp_resource_exec_result`
- `fetch_args`
-> `fetch_result`
- `record_screen_args`
-> `record_screen_result`
- `computer_use_args`
-> `computer_use_result`
- `write_shell_stdin_args`
-> `write_shell_stdin_result`
- `execute_hook_args`
-> `execute_hook_result`
- `subagent_args`
-> `subagent_result`
所有这些结果面回包都带:
- `id`
- `exec_id`
服务端匹配时通常优先用:
1. `exec_id`
2. `id`
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
除了结果面回包外,客户端还可能回控制面消息:
- `stream_close`
- 表示当前 exec 流已关闭
- 只有 `id`
- `throw`
- 表示执行异常
- 只有 `id` + `error` + 可选 `stack_trace`
- `heartbeat`
- 表示执行过程中的心跳
- 只有 `id`
### 关键理解
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
- 它是跨 exec 通用的控制面回包
- 因此调查时必须同时看两类上行:
- `ExecClientMessage`
- `ExecClientControlMessage`
### 调查规则
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
- 收到对应的 `ExecClientMessage`
- 或收到 `ExecClientControlMessage.throw`
- 对流式 exec,还要看:
- 是否有多次增量 `ExecClientMessage`
- 是否最终有 `stream_close`
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
排查时优先搜索这些关键字:
## 3. forwarder 状态机实现规则
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
### 3.1 resume 必须按 provider pass 隔离
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
- resume 请求必须带来源 pass,至少要能区分:
- 当前 pass 的结果触发的合法续跑
- 上一轮工具终态晚到造成的陈旧 resume
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
### 3.2 工具晚到是常态,只能影响所属 pass
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
### 3.3 pending exec 必须严格按 id 匹配
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
- `exec_id`
- `message_id`
进行匹配。
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
- 优先看 `RecentCompletedExecs` 做幂等忽略
- 不要把它重新落到当前轮的 pending 上
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
- 同一个 `request_id``[DONE]` 后又出现新的 `model_call_id`
- `turns/<n+1>/request.json``turns/<n>/request.json` messages 几乎完全相同
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
优先核对:
- `ProviderPassCount`
- resume 请求的来源 pass
- `PendingExec.ProviderPass`
- `selectPendingExec` 是否存在跨轮误匹配
- `startYieldingInputsToTheServer`
- `bidiAppend({requestId:A,appendSeqno`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
## 3. 对本地模式最重要的协议理解
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
- 如果客户端不回对应结果,服务端 pending 不会收口
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
- 一般不要求客户端再回一个“完成”消息
## 4. 调查本地模式时的优先顺序
1. 先确认收到的 `AgentServerMessage` 是哪一类
2. 如果是 `exec_server_message`
- 查客户端是否回了 `ExecClientMessage`
- 查是否只回了 `stream_close` 但没有真正结果
-`exec_id` / `id` 是否匹配
3. 如果是 `interaction_query`
- 查客户端是否回了 `InteractionResponse`
4. 如果是 `conversation_checkpoint_update`
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
- 不要误以为它本身需要回 ack
## 5. 对服务端实现的直接要求
- 服务端必须区分“请求型下行”和“通知型下行”
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
- 对请求型消息,必须在本地状态机里维护 pending:
- `PendingExec`
- `PendingInteraction`
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
@@ -0,0 +1,4 @@
interface:
display_name: "本地模式实现指南"
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
@@ -0,0 +1,107 @@
---
name: cursor-app-formatted
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
---
# Cursor App Formatted Snapshot
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
## Invariants
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
## Preferred Investigation Flow
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
3. Prefer stable formatted paths for line references and control-flow reading:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
## Git Ignore Rule
Ensure `.gitignore` contains:
```gitignore
.cursor-app-formatted/
```
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
## Snapshot Generation Workflow
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
```bash
set -euo pipefail
SNAPSHOT=.cursor-app-formatted
SOURCE=/Applications/Cursor.app/Contents/Resources/app
rm -rf "$SNAPSHOT"
mkdir -p "$SNAPSHOT"
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
/usr/bin/shasum -a 256 \
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
> "$SNAPSHOT/source-sha256.txt"
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
| /usr/bin/sed "s#^$SOURCE/##" \
| while IFS= read -r rel; do
/usr/bin/shasum -a 256 "$SOURCE/$rel"
done >> "$SNAPSHOT/source-sha256.txt"
```
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
```bash
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
EMPTY_IGNORE="$(mktemp)"
trap 'rm -f "$EMPTY_IGNORE"' EXIT
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
```
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
## Validation
After generation, verify the snapshot is ignored and key files are readable:
```bash
git status --short --ignored | rg '\.cursor-app-formatted'
wc -l \
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
```
Useful investigation check:
```bash
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
```
@@ -0,0 +1,97 @@
---
name: cursor-client-e2e-debugging
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
---
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
当用户只给一个 UUID / id,希望反查它是 `conversationId``requestId``modelCallId``toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
## 首要约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite``conversation.json``turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
## 先做路由判断
- `history + logs` 反查层
- 现象:用户发来一个 id,要判断它是 `conversationId``requestId``modelCallId``toolCallId`;需要从 `history/<conversationId>/state.json``history/<conversationId>/context.json` 追运行状态和语义历史。
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- `provider replay / debug`
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
- `cursor-agent`
- 现象:`CursorAgentProvider``ClaudeSDKClient``AnthropicProxy``registerAgentProvider``InteractionUpdate` 映射、模型桥接异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- `cursor-always-local` / 本地模式协议层
- 现象:`BidiAppend``RunSSE``AgentServerMessage``ExecClientMessage``InteractionResponse`、live checkpoint / pending 收口异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- 客户端 bundle 只读定位层
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
## 当前工作流
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items``state.json``logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`
2. 一旦拿到 `conversationId`,同时看两份事实源:
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`append-only 的语义历史 entriesprompt replay 由 `ProjectPromptReplay()` 从这里投影。
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json``AppendLLMResponseChunk` 是 no-op`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
4. 再确认故障主要落在 `cursor-agent``cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
- 通过 id 反查拿到 `conversationId``requestId``modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
7. 如果用户要核对 prefix cache / cache hit
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
- 它读取当前 `history/<conversationId>/state.json``history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
8. 如果需要对照已安装 app 与仓库行为:
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
- 先确认实际运行的 app 副本和目标 bundle 路径。
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
9. 如果证据显示问题更像是客户端 bundle 行为差异:
- 记录具体文件、符号、日志和协议证据链。
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
## 约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId``requestId``modelCallId``toolCallId`
- 不要把 `history/<conversationId>/state.json``history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
- 不要再依赖 `agent_request_runs``agent_conversations``agent_history_entries``protocol_traces``data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
## 模型渠道规则
- 模型渠道唯一性不再由 `modelID` 决定。
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
- resolver 仍兼容 legacy 渠道 ID`baseURL + modelID + apiKey + displayName`
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`
## 参考加载规则
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
- 文件地图:读 [references/file-map.md](references/file-map.md)
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
## 自带脚本
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG``REQUEST_ID``MODEL_CALL_ID`
@@ -0,0 +1,177 @@
# backend/store 日志反查
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
## 当前固定路径
当前用户机器上的固定助手根目录:
- `~/.cursor-local-assistant-v2`
当前最关键的是三类内容:
- `history/<conversationId>/state.json`
- 会话元数据与当前状态。
- 重点字段:`request_id` `conversation_id``root_conversation_id``parent_conversation_id``parent_tool_call_id``mode``current_loop_status``current_request_id``current_turn_seq``context_version``next_turn_seq``next_entry_seq``latest_request_prefix``last_provider_call``current_todos``current_plans`、token / compaction 字段。
- `history/<conversationId>/context.json`
- append-only 语义历史。
- 重点字段:`version``items[]`
- `items[]` 中每个 entry 通常有 `seq``turn_seq``request_id``role``kind``tool_call_id``parent_tool_call_id``payload``created_at`
- `history/usage.json`
- provider call 与 turn usage 聚合。
- 重点字段:`totals``daily``recent_events``event_index`
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
当前实现不再支持:
- DB-backed store / searchable conversation memory
- HTTP/protocol trace debug UI
- `data/data.sqlite` / `protocol_traces`
- `history/<conversationId>/conversation.json`
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
- 根目录或会话目录下的旧 `latest.json``summary.json``replay.json``runtime.json``request.json``recovery.json``entries.jsonl`、数字 turn 目录
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
## 用户发来一个 id 时的固定步骤
### 1. 先判断 id 类型
不要先假设它是 `requestId`。按这个顺序缩小范围:
1. 是否是 `conversationId`
- 检查 `history/<id>/state.json``history/<id>/context.json` 是否存在。
2. 是否是 `requestId`
-`history/*/state.json` 中查 `current_request_id``latest_request_prefix.request_id``last_provider_call.request_id`
-`history/*/context.json``items[].request_id` 中查。
-`logs/app.log` 中查。
3. 是否是 `modelCallId`
-`state.json` 中查 `latest_request_prefix.model_call_id``last_provider_call.model_call_id`
-`context.json.items[].payload` 中查 `model_call_id`
-`logs/app.log` 中查 `model_call_id=<id>`
4. 是否是 `toolCallId` / `exec_id`
-`context.json.items[].tool_call_id``items[].payload` 中查。
- 在协议/工具相关日志中查。
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
### 2. 拿到 `conversationId` 后看两份事实源
```bash
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
CONV_ID="<conversation-id>"
ls -la "$HISTORY_ROOT/$CONV_ID"
```
重点检查:
- `state.json`
- `current_loop_status``idle``running``waiting_tool``completed``canceled``provider_error``failed`
- `current_request_id``current_turn_seq`
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
- `last_provider_call`:最近 provider 状态与错误文本
- `next_entry_seq``next_turn_seq``context_version`
- `current_todos``current_plans`
- `context.json`
- `version` 是否与 `state.context_version` 对齐
- `items[]` 是否按 `seq` 稳定递增
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
- 是否有重复、缺失或顺序异常
- `usage.json`
- 通过 `event_index``recent_events` 查 request/model-call 相关 usage
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
### 3. Provider 调用证据现在在哪里
当前 provider artifact recorder 的行为:
- `RecordLLMRequest(...)`
- 只缓存当前 provider call 的请求摘要。
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`
- 不再写 `request.json`
- `AppendLLMResponseChunk(...)`
- 当前是 no-op。
- 不再写 `sse.jsonl`
- `RecordLLMSummary(...)`
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`
- usage 聚合写入 `history/usage.json`
- 不再写 `summary.json`
所以 provider 错误排查应优先看:
- `state.last_provider_call`
- `state.latest_request_prefix`
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
- `history/usage.json`
- `logs/app.log`
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
## 这些文件是怎么生成的
### 根路径
- `internal/appdata/paths.go`
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
- `HistoryRootPath()``~/.cursor-local-assistant-v2/history`
- `UsageFilePath()``~/.cursor-local-assistant-v2/history/usage.json`
- `LogsRootPath()``~/.cursor-local-assistant-v2/logs`
### `state.json + context.json`
来源链路:
- `internal/backend/forwarder/file_store.go`
- `CreateConversation`
- `LoadConversation`
- `AppendEntries`
- `SaveConversationWithEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `internal/backend/forwarder/service.go`
- `handleRunIntent` 开始新 loop / turn
- `appendConversationEntries` 追加语义事件
- `internal/backend/forwarder/projector.go`
- `ProjectPromptReplay()``context.json.items` 投影为 provider messages
稳定结论:
- `state.json` 是当前状态和可变元数据。
- `context.json.items` 是 replayable 语义历史。
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`
### `usage.json`
来源链路:
- `internal/backend/forwarder/usage_store.go`
- `UsageFileStore.UpsertEvent`
- `UsageFileStore.LookupEvent`
- `internal/backend/forwarder/token_usage.go`
- `internal/historymetrics/`
稳定结论:
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`
### legacy 清理
来源链路:
- `internal/backend/forwarder/history_maintenance.go`
稳定结论:
- `turns/``conversation.json``entries.jsonl``request.json``summary.json` 等都是 legacy artifact。
- 启动后的 history maintenance 会清理这些旧产物。
## 快速判断规则
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`
- 请求失败时,先看 `state.last_provider_call``context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
@@ -0,0 +1,147 @@
# 文件地图
## 已安装客户端 bundle
优先核对这些实际运行中的客户端文件:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec``cursor-agent-worker``cursor-always-local` 及其 `dist/` 文件。
大致归属:
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend``RunSSE``AgentServerMessage` / `AgentClientMessage` 桥接。
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunkagent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
- `cursor-agent-worker/dist/main.js`agent worker 侧后台逻辑。
用户机器上可能还存在其它 app 副本,例如:
- `~/Applications/Cursor Hooked.app`
- `/Applications/Cursor Patched.app`
不要假设哪一个在跑,先看进程路径。
## 当前 backend/store 与 history
当前用户机器上的固定助手目录:
- `~/.cursor-local-assistant-v2/`
重点看:
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/usage.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
- `~/.cursor-local-assistant-v2/logs/app.log`
其中:
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
- `usage.json` 是全局 provider call / turn usage 聚合。
- `conversation.lock` 是会话级文件锁。
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
- legacy artifacts`conversation.json``entries.jsonl``turns/``request.json``summary.json``sse.jsonl``replay.json``runtime.json``latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
这些内容的生成入口主要在:
- `internal/appdata/paths.go`
- `internal/backend/host.go`
- `internal/backend/README.md`
- `internal/backend/forwarder/file_store.go`
- `internal/backend/forwarder/history_maintenance.go`
- `internal/backend/forwarder/usage_store.go`
- `internal/backend/forwarder/token_usage.go`
- `internal/backend/forwarder/artifacts.go`
## 本仓库协议与本地模式实现
协议定义:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `proto/from_extensions/agent_v1.proto`
- `proto/from_extensions/aiserver_v1.proto`
扩展快照与提取:
- `proto/extensions-cursor-app/cursor-always-local/package.json`
- `proto/extract_extensions_proto.sh`
- `proto/ext_tool/main.go`
本地后端入口:
- `internal/backend/host.go`
- `internal/backend/server/route.go`
- `internal/backend/server/policy.go`
- `internal/backend/server/local.go`
- `internal/backend/server/config/types.go`
- `internal/backend/server/config/manager.go`
- `internal/backend/server/config/resolver.go`
forwarder 主链路:
- `internal/backend/forwarder/module.go`
- `internal/backend/forwarder/service.go`
- `internal/backend/forwarder/actor.go`
- `internal/backend/forwarder/broker.go`
- `internal/backend/forwarder/events.go`
- `internal/backend/forwarder/compiler.go`
- `internal/backend/forwarder/projector.go`
- `internal/backend/forwarder/provider.go`
- `internal/backend/forwarder/checkpoint_memory.go`
- `internal/backend/forwarder/runtime_summary.go`
协议解码:
- `internal/backend/agent/protocol/inbound.go`
执行桥 / 交互桥:
- `internal/backend/agent/bridge/exec/bridge.go`
- `internal/backend/agent/bridge/interaction/bridge.go`
模型适配:
- `internal/backend/agent/model/router.go`
- `internal/backend/agent/model/openai.go`
- `internal/backend/agent/model/anthropic.go`
- `internal/backend/agent/model/artifacts.go`
- `internal/backend/agent/model/http_error.go`
- `internal/backend/agent/model/tool_call_id.go`
- `internal/modelchannel/identity.go`
- `internal/runtime/local_runtime.go`
Prompt / replay
- `internal/backend/agent/prompt/engine.go`
- `internal/backend/agent/prompt/replay.go`
- `internal/backend/agent/prompt/content_parts.go`
- `internal/backend/forwarder/prompt_context.go`
- `internal/backend/forwarder/request_context.go`
- `internal/backend/forwarder/reminders.go`
- `internal/backend/forwarder/prompt_guard.go`
## 构建相关参考(只读)
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
- `Taskfile.yml`
- `build/darwin/Taskfile.yml`
- `build/dmg-extras/提示损坏?点我.command`
重点看:
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
@@ -0,0 +1,87 @@
# 已安装客户端只读核对与验证
首要原则:
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
## 1. 先确认实际运行的 app 副本
优先用非交互命令核对:
```bash
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
```
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
## 2. 只读定位目标 bundle 与关键文件
优先定位并读取这些文件,而不是改写它们:
```bash
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
```
重点关注:
- `out/vs/workbench/workbench.desktop.main.js`
- `out/vs/workbench/api/node/extensionHostProcess.js`
- `cursor-always-local/dist/main.js`
- `cursor-always-local/dist/gitWorker.js`
- `cursor-agent-exec/dist/main.js`
- `cursor-agent-exec/dist/*.js`
- `cursor-agent-worker/dist/main.js`
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
## 3. 只读读取 bundle 内容
常用定位关键词:
```bash
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
```
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
如果需要和仓库实现对照,优先同时打开:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `internal/backend/...`
- `internal/runtime/local_runtime.go`
## 4. 验证行为是否命中目标副本
至少做其中两项:
- 进程路径是否是目标 app
- 目标扩展 host 是否起来
- 本地监听端口是否存在
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
常用验证:
```bash
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
```
## 5. 记录证据并输出归因
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
1. 实际运行的 app 路径
2. 命中的 bundle 文件路径与关键符号
3. 对应日志、端口、`history/state.json``history/context.json``usage.json` 证据
4. 仓库里对应实现的位置
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
@@ -0,0 +1,153 @@
# Provider replay / debug
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
## 证据边界
- `history/<conversationId>/debug/provider.jsonl`
- 最接近 provider 出站边界。
- `event=llm_request``payload.body` 是最终 provider request body。
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
- `history/<conversationId>/state.json`
- 当前状态和最近 provider 摘要。
- 重点看 `latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`
- replayable 语义历史。
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
- `history/<conversationId>/debug/bidi.raw.jsonl`
- 客户端原始上行字节证据。
- `history/<conversationId>/debug/bidi.decoded.jsonl`
- 当前 known-schema 解码后的客户端上行证据。
- `history/<conversationId>/debug/runtime.jsonl`
- 后端把哪些字段挂到 active request / stream 上。
- `history/<conversationId>/debug/runsse.jsonl`
- 后端尝试发回客户端的消息。
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
## 前置条件
- 请求发生时 `config.yaml``log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`
- 已经通过 id 反查拿到:
- `conversationId`
- `requestId`
- `modelCallId`
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
如果没有 debug 文件,先回到 `state.json``context.json``usage.json``logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
## 最小流程
1. 定位 provider debug 文件:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
CONV="<conversationId>"
REQ="<requestId>"
MODEL_CALL="<modelCallId>"
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
```
2. 确认 `llm_request` 存在:
```bash
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
' "$REQUEST_LOG"
```
3. 执行通用重放脚本:
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
CHANNEL_NAME="GLM" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
也可以直接传入 provider 配置,避免读取 `config.yaml`
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
BASE_URL="<provider-base-url>" \
API_KEY="<provider-api-key>" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
4. 查看产物:
- `request.body.json`:抽取出的最终 provider body。
- `response.headers`HTTP 响应头。
- `response.sse`SSE 响应体。
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
## 结果判断
- `curl_exit_code != 0`
- 网络、TLS、超时、连接或本机 curl 问题。
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
- HTTP 非 2xx
- provider 网关或鉴权层拒绝。
- 优先看 `response.headers` 和 provider 错误体。
- HTTP 2xx 但 SSE 中有 `event: error`
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
- SSE 正常流式输出
- 原始 provider 请求形状基本可用。
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
## 常见收敛方向
provider 参数错误时,优先检查:
- `model` 是否是目标 endpoint 支持的名称。
- `messages` 是否符合 Anthropic-compatible 形态。
- `system` 是否被目标 provider 支持,或需要改成 message。
- `tools` / `tool_choice` 是否符合目标 provider 方言。
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
- `max_tokens``temperature``top_p``stop_sequences` 是否落在 provider 允许范围内。
## 敏感信息规则
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
## 脚本参数
`scripts/provider-replay.sh` 使用环境变量控制:
- 必填:
- `REQUEST_LOG`
- `REQUEST_ID`
- `MODEL_CALL_ID`
- 可选:
- `BASE_URL`
- `API_KEY`
- `GLM_BASE_URL`
- `GLM_API_KEY`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
- `CHANNEL_NAME`,默认 `GLM`
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
- `MAX_TIME`,默认 `240`
- `ENDPOINT_PATH`,默认 `/v1/messages`
脚本输出四个稳定产物:
- `request.body.json`
- `response.headers`
- `response.sse`
- `replay.meta.json`
@@ -0,0 +1,223 @@
# 搜索词与判断树
## 先判断层级
### `history + logs` / id 反查层
当现象是“用户只给了一个 id”“需要判断它是 `conversationId``requestId``modelCallId``toolCallId`”“要从本地 history 和日志追状态”:
优先搜索:
- `state.json`
- `context.json`
- `usage.json`
- `logs/app.log`
- `conversation_id`
- `current_request_id`
- `request_id`
- `model_call_id`
- `tool_call_id`
- `latest_request_prefix`
- `last_provider_call`
- `current_loop_status`
- `context_version`
- `next_entry_seq`
- `next_turn_seq`
- `LoadConversation`
- `CreateConversation`
- `SaveConversationWithEntries`
- `AppendEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `ProjectPromptReplay`
- `UsageFileStore`
- `UpsertEvent`
- `LookupEvent`
不要再优先搜索或依赖:
- `data.sqlite`
- `protocol_traces`
- `agent_request_runs`
- `conversation.json`
- `entries.jsonl`
- `turns/<n>`
- `request.json`
- `sse.jsonl`
- `summary.json`
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
### `cursor-agent-exec` / `cursor-agent-worker` 层
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
优先在已安装客户端 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
优先搜索:
- `registerAgentProvider`
- `CursorAgentProvider`
- `CursorAgentProviderHandle`
- `ClaudeSDKClient`
- `streamInteractionUpdates`
- `handlePartialMessage`
- `AnthropicProxy`
- `getAnthropicProxyPort`
- `getAnthropicProxyAuthToken`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `InteractionUpdate`
- `checkpoint`
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
### agent window / conversation metadata UI 层
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
优先在已安装客户端主 UI 和 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
优先搜索:
- `shouldShowAgentWindowTitleHelperText`
- `glass_open_agents_titlebar_button`
- `open_agent_window_top`
- `open_agent_window_bottom_convo`
- `glass.enable_open_agent_in_window`
- `NameAgentRequest`
- `NameAgentResponse`
- `UpdateConversationMetadataRequest`
- `UpdateConversationMetadataResponse`
- `CreateTranscriptOverviewRequest`
- `createTranscriptOverview`
- `updateConversationMetadata`
- `conversation_checkpoint_update`
判断规则:
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
### `cursor-always-local` / 协议层
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
优先搜索:
- `BidiTransport`
- `startYieldingInputsToTheServer`
- `BidiAppend`
- `RunSSE`
- `AgentServerMessage`
- `AgentClientMessage`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
- `conversation_checkpoint_update`
### 本仓库 forwarder 层
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
优先搜索:
- `handleRunIntent`
- `driveProvider`
- `startStreamActor`
- `streamCommandEnvelope`
- `handleToolInvocation`
- `handleExecResult`
- `handleExecControl`
- `publishCheckpoint`
- `CheckpointConversation`
- `snapshotCheckpointConversation`
- `appendConversationEntries`
- `OpenExec`
- `OpenQuery`
- `StartStream`
- `deriveConversationLoopState`
- `historyEntryToolCallID`
- `recordProviderUsage`
- `recordTurnUsage`
### provider / 模型适配层
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
优先搜索:
- `StartStream`
- `StreamRequest`
- `ResolvedChannelID`
- `ResolvedChannelName`
- `ProviderModelID`
- `ThinkingEnabled`
- `buildAnthropicThinkingConfig`
- `normalizeAnthropicProviderMessages`
- `normalizeOpenAIProviderMessages`
- `normalizeOpenAIResponsesInput`
- `reasoning_content`
- `ReasoningContent`
- `ReasoningSignature`
- `RecordLLMRequest`
- `RecordLLMSummary`
- `http_error`
- `namespaceToolCallID`
## 快速判断规则
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json``history/*/context.json``logs/app.log`
- 如果问题是“模型输出语义不对”,先看 `context.json.items``ProjectPromptReplay()` 的投影,再看 provider request normalization。
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix``state.last_provider_call``logs/app.log`
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpointcheckpoint 是 live state,重启后的事实源是 `state.json + context.json`
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
## 协议关键词
上行:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
下行:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `interaction_query`
- `conversation_checkpoint_update`
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
- `exec_id`
- `id`
- `tool_call_id`
- `request_id`
- `model_call_id`
- pending 收口逻辑
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
- `history/<id>/state.json`
- `history/*/state.json``current_request_id``latest_request_prefix``last_provider_call`
- `history/*/context.json``items[].request_id``items[].tool_call_id``items[].payload`
- `history/usage.json``event_index` / `recent_events`
- `logs/app.log`
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
const repoRoot = path.resolve(skillRoot, "../../..");
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
const child = spawn("go", args, {
cwd: repoRoot,
stdio: "inherit",
});
child.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`cache-hit-rate.mjs failed: ${message}`);
process.exitCode = 1;
});
child.on("exit", (code, signal) => {
if (typeof code === "number") {
process.exitCode = code;
return;
}
if (signal) {
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
}
process.exitCode = 1;
});
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
Required:
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
REQUEST_ID Provider request_id to replay
MODEL_CALL_ID Provider model_call_id to replay
Optional:
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
MAX_TIME curl max-time seconds. Defaults to 240.
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
REQUEST_LOG="${REQUEST_LOG:-}"
REQUEST_ID="${REQUEST_ID:-}"
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
MAX_TIME="${MAX_TIME:-240}"
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
BODY_FILE="$OUT_DIR/request.body.json"
RESP_FILE="$OUT_DIR/response.sse"
HEADER_FILE="$OUT_DIR/response.headers"
META_FILE="$OUT_DIR/replay.meta.json"
require_value() {
local name="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "缺少 $name。运行 --help 查看用法。" >&2
exit 2
fi
}
read_channel_config() {
local field="$1"
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
import sys
from pathlib import Path
config_path = Path(sys.argv[1]).expanduser()
channel_name = sys.argv[2]
field = sys.argv[3]
if not config_path.exists():
raise SystemExit
lines = config_path.read_text(encoding="utf-8").splitlines()
in_channel = False
for line in lines:
stripped = line.strip()
if stripped.startswith("- displayName:"):
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
continue
if in_channel and stripped.startswith(field + ":"):
print(stripped.split(":", 1)[1].strip().strip('"'))
raise SystemExit
PY
}
require_value "REQUEST_LOG" "$REQUEST_LOG"
require_value "REQUEST_ID" "$REQUEST_ID"
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
if [[ ! -f "$REQUEST_LOG" ]]; then
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
exit 2
fi
if [[ -z "$BASE_URL" ]]; then
BASE_URL="$(read_channel_config baseURL || true)"
fi
if [[ -z "$API_KEY" ]]; then
API_KEY="$(read_channel_config apiKey || true)"
fi
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
mkdir -p "$OUT_DIR"
: > "$HEADER_FILE"
: > "$RESP_FILE"
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
import json
import sys
from pathlib import Path
log_path = Path(sys.argv[1]).expanduser()
request_id = sys.argv[2]
model_call_id = sys.argv[3]
body_path = Path(sys.argv[4])
meta_path = Path(sys.argv[5])
body = None
meta = None
with log_path.open(encoding="utf-8") as f:
for raw in f:
if not raw.strip():
continue
row = json.loads(raw)
if row.get("event") != "llm_request":
continue
if row.get("request_id") != request_id:
continue
if row.get("model_call_id") != model_call_id:
continue
payload = row.get("payload") or {}
body = payload.get("body")
meta = {
"at": row.get("at"),
"event": row.get("event"),
"conversation_id": row.get("conversation_id"),
"request_id": row.get("request_id"),
"model_call_id": row.get("model_call_id"),
"provider_log": str(log_path),
}
break
if body is None:
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
url="${BASE_URL%/}${ENDPOINT_PATH}"
set +e
curl --no-buffer --silent --show-error \
--connect-timeout 30 \
--max-time "$MAX_TIME" \
--request POST "$url" \
--header "content-type: application/json" \
--header "anthropic-version: 2023-06-01" \
--header "User-Agent: claude-cli/1.0.25" \
--header "x-api-key: $API_KEY" \
--header "Authorization: Bearer $API_KEY" \
--data-binary "@$BODY_FILE" \
--dump-header "$HEADER_FILE" \
--output "$RESP_FILE"
code=$?
set -e
echo "curl_exit_code=$code"
echo "body=$BODY_FILE"
echo "headers=$HEADER_FILE"
echo "response=$RESP_FILE"
echo "meta=$META_FILE"
echo "--- response headers ---"
if [[ -f "$HEADER_FILE" ]]; then
python3 - "$HEADER_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
print(line)
PY
fi
echo "--- response first 120 lines ---"
if [[ -f "$RESP_FILE" ]]; then
python3 - "$RESP_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
print(line)
PY
else
echo "响应文件不存在。"
fi
exit "$code"
+176
View File
@@ -0,0 +1,176 @@
---
name: cursor-debug-log
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
---
# Cursor Debug Log
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
## 作用定位
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
- 用来回答“客户端到底发了什么”。
- 用来回答“后端解码后认为这是什么请求”。
- 用来回答“哪些字段被挂到了当前 active request 上”。
- 用来回答“最终 provider request body 是什么样”。
- 用来回答“RunSSE 实际给客户端发送了什么”。
- 不要把它当成 replay history、prompt 输入或状态事实源。
稳定事实源仍然是:
- `history/<conversationId>/state.json`
- `history/<conversationId>/context.json`
- `history/usage.json`
- `logs/app.log`
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
## 固定路径
- 助手根目录:`~/.cursor-local-assistant-v2`
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
- history 根目录:`~/.cursor-local-assistant-v2/history`
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
- 会话 debug 目录:`history/<conversationId>/debug/`
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
通过配置开启 debug logging
```yaml
log: true
```
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
## 文件如何生成
debug 层随着请求穿过后端边界逐步落盘:
1. `BidiAppend` 收到客户端上行数据。
- 原始 hex 写入 `bidi.raw.jsonl`
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`
2. forwarder 把解码结果转成 active runtime state。
- stream/request 状态决策写入 `runtime.jsonl`
3. provider pass 被准备并执行。
- adapter 前的请求摘要、`model_call_id``provider_pass` 等写入 `provider.jsonl`
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`
4. `RunSSE` 把后端输出流式发送给客户端。
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
## Debug 文件含义
`bidi.raw.jsonl`
- 方向:客户端到后端。
- 包含 `request_id`、可选 `conversation_id``append_seqno``status`、原始 `data_hex`
- 当需要精确确认客户端上传字节时先看它。
`bidi.decoded.jsonl`
- 方向:客户端到后端,protobuf 解码后。
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson`message`
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message``user_message``request_context``conversation_state`、exec/interaction/kv 回包等。
- 还包含 `message_case``requested_model``conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message``intent` 里的完整字段。
`runtime.jsonl`
- 方向:后端内部 runtime。
- 包含状态流转,以及挂到 active stream/request 上的字段。
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
`provider.jsonl`
- 方向:后端到 provider adapter/provider。
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
`runsse.jsonl`
- 方向:后端到客户端。
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
## 查询流程
1. 先判断 id 类型。
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`
- 再在 `history/*/{state.json,context.json}``history/usage.json``logs/app.log` 里搜索 request/model-call/tool id。
2. 拿到 `conversationId` 后,列出 debug 目录。
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
- 读取 `config.yaml`
- 对比 `config.yaml``state.json``context.json` 的 mtime。
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
4. 按时间顺序读 JSONL,并用这些字段串联:
- `request_id`
- `conversation_id`
- `model_call_id`
- `provider_pass`
- `append_seqno`
- event timestamp
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
常用命令:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
REQ="<requestId>"
CONV="<conversationId>"
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
find "$ROOT/history" -path "*/debug/*" -type f | sort
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
```
紧凑查看 JSONL
```bash
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
```
## 证据怎么用
根据问题选择对应文件:
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl``message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
- 后端如何理解请求:看 `bidi.decoded.jsonl``intent`,再接 `runtime.jsonl`
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`
- UI/流式输出问题:看 `runsse.jsonl`
- 请求状态问题:先看 `state.json``context.json``usage.json`,再用 debug 文件补证。
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
- `provider.jsonl` 说明最终为 provider 准备了什么。
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
注意证据边界:
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
## Debug 文件缺失
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
- 请求发生时 `log: false`
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`
- 请求在开启 `log` 前已经完成。
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
debug 证据缺失时,回退到 `state.json``context.json``usage.json``logs/app.log`,并把结论标成推断,而不是直接证明。
+42
View File
@@ -0,0 +1,42 @@
---
name: i18n-requirements
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
---
# I18n Requirements
## Source Messages
- Treat `zh-CN` as the only source locale.
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
## Generated Catalogs
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
## Adding A Locale
Update all of these integration points together:
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
- Native tray labels in `internal/app/runner.go`.
## Verification
After the scan build:
1. Confirm `npm run build` succeeds.
2. Confirm every locale JSON has the same keys as `catalog.json`.
3. Confirm non-source locale files contain no empty values.
4. Confirm translated placeholders match the source entry placeholders.
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
@@ -0,0 +1,4 @@
interface:
display_name: "I18n Requirements"
short_description: "Keep UI translations and generated catalogs in sync"
default_prompt: "Use $i18n-requirements to update localized UI text safely."
@@ -0,0 +1,35 @@
---
name: prefix-cache-stability
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
---
# Prefix Cache Stability
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
## Hard Constraints
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
- Do not move previously sent model-visible messages to a new position in later requests.
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
- Never optimize cache by dropping correctness-critical context.
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
## Implementation Pattern
1. Classify each prompt addition:
- Stable system policy: belongs in the fixed system prompt.
- Historical model-visible context: persist as replayable history.
- Latest-only attention: append as current suffix, do not persist.
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
## Verification
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
+9
View File
@@ -0,0 +1,9 @@
*
!go.mod
!go.sum
!cursor-tab-server/
!cursor-tab-server/**
!gen/
!gen/**
!internal/
!internal/**
+18
View File
@@ -0,0 +1,18 @@
## 变更说明 / What
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
## 关联 Issue / Related Issue
<!-- 例如 / e.g. Closes #161 -->
## 测试方式 / How to Test
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
## 检查清单 / Checklist
- [ ] 代码已通过 `gofmt``go vet` / Code passes `gofmt` and `go vet`
- [ ] 前端构建无报错 / Frontend builds without errors
- [ ] 新增 UI 文案已同步所有 locale 文件 / New UI strings synced to all locale files
- [ ] 提交信息符合 Conventional Commits 规范 / Commit messages follow Conventional Commits
+27
View File
@@ -0,0 +1,27 @@
claude-server.tar
dist
.task
bin
logs/
gen/
logs.zip
frontend/bindings
dist
node_modules
cursor-server.tar
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
.cursor-local-assistant
.cursor-local-assistant-v2
.cursor-app-formatted/
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
+90
View File
@@ -0,0 +1,90 @@
# 贡献指南
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
感谢你考虑为 cursor-byok 做出贡献!
## 开发环境
| 依赖 | 版本要求 |
|------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Linux 额外依赖:`libgtk-3-dev``libwebkit2gtk-4.1-dev`Wails 运行时需要)。
## 快速开始
```bash
# 安装前端依赖
cd frontend && yarn install --frozen-lockfile && cd ..
# 启动开发模式(热重载)
task dev
# 构建当前平台分发包
task build
```
## 项目结构
```
├── main.go # 入口
├── internal/ # Go 后端(代理、转发、客户端管理等)
├── frontend/ # Vue 3 + Vite + Tailwind 前端
│ ├── src/
│ │ ├── views/ # 页面
│ │ ├── components/ # 组件
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU
│ │ └── state/ # 全局状态
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
├── prompt/ # 内置 Agent prompt 模板
├── proto/ # Protobuf 定义
├── build/ # 构建配置与平台 Taskfile
├── scripts/ # 辅助脚本(release、metrics
└── Taskfile.yml # 顶层任务编排
```
## 开发规范
### 提交信息
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
```
feat(proxy): 支持自定义 upstream 超时
fix(i18n): 补全日语翻译缺失 key
release: 0.0.42
```
### 代码风格
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
- 前端:Vue SFC + Composition APITailwind 工具类优先。
- 新增 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
@@ -0,0 +1,90 @@
# Contributing Guide
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
Thank you for considering contributing to cursor-byok!
## Prerequisites
| Dependency | Version |
|------------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
## Quick Start
```bash
# Install frontend dependencies
cd frontend && yarn install --frozen-lockfile && cd ..
# Start dev mode (hot reload)
task dev
# Build for current platform
task build
```
## Project Structure
```
├── main.go # Entry point
├── internal/ # Go backend (proxy, forwarding, client management)
├── frontend/ # Vue 3 + Vite + Tailwind frontend
│ ├── src/
│ │ ├── views/ # Pages
│ │ ├── components/ # Components
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
│ │ └── state/ # Global state
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
├── prompt/ # Built-in agent prompt templates
├── proto/ # Protobuf definitions
├── build/ # Build configs & platform Taskfiles
├── scripts/ # Helper scripts (release, metrics)
└── Taskfile.yml # Top-level task orchestration
```
## Development Guidelines
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(proxy): support custom upstream timeout
fix(i18n): add missing Japanese translation keys
release: 0.0.42
```
### Code Style
- Go: follow `gofmt` / `go vet`; no additional linter config.
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
### Branching & PRs
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
2. Keep PRs small and focused — one problem per PR.
3. Describe motivation and how to test in the PR description.
## Build & Release
```bash
# Build all platforms (macOS host only)
task build:all
# Prepare release assets
task release:prepare
# Publish to GitHub Releases
task release:github
```
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 leookun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39 -2
View File
@@ -1,2 +1,39 @@
修复模型🔒,需要大退Cursor生效
修复Rule重复问题
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
## 交流群组
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 可以自由接入到你想使用的任何工具中。
+569
View File
@@ -0,0 +1,569 @@
version: "3"
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
vars:
APP_NAME: "Cursor助手"
BIN_DIR: "bin"
APP_VERSION:
sh: 'go run ./scripts/release version -config ./build/config.yml'
RELEASE_REPO: "leookun/cursor-byok"
RELEASE_BASE_NAME: "cursor-byok"
RELEASE_SOURCE_PATH: "release-notes.md"
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
SCAN: '{{.SCAN | default "false"}}'
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
CURRENT_LINUX_ARCH: "amd64"
CURRENT_LINUX_NAME: "linux-amd64"
tasks:
build:
summary: 构建当前系统分发包
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
cmds:
- task: clean:dist
- task: build:current
run:
summary: 运行当前系统版本
cmds:
- task: "{{OS}}:run"
build:all:
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
cmds:
- task: clean:dist
- task: build:darwin:arm64
- task: build:darwin:amd64
- task: build:windows:386
- task: build:windows:amd64
build:current:
internal: true
cmds:
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
build:darwin:current:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: '{{ARCH}}'
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
SCAN: '{{.SCAN}}'
build:darwin:arm64:
internal: false
cmds:
- task: darwin:package:dmg
vars:
ARCH: arm64
BINARY_NAME: macos-arm64
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
APP_BUNDLE: macos-arm64.app
DMG_NAME: macos-arm64.dmg
SCAN: '{{.SCAN}}'
build:darwin:amd64:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: amd64
BINARY_NAME: macos-intel
OUTPUT: '{{.BIN_DIR}}/macos-intel'
APP_BUNDLE: macos-intel.app
DMG_NAME: macos-intel.dmg
SCAN: '{{.SCAN}}'
build:windows:current:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
SCAN: '{{.SCAN}}'
build:windows:386:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: 386
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
ZIP_NAME: windows-32.zip
SCAN: '{{.SCAN}}'
build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
ZIP_NAME: windows-64.zip
SCAN: '{{.SCAN}}'
build:linux:current:
internal: true
cmds:
- task: linux:package:archive
vars:
ARCH: '{{.CURRENT_LINUX_ARCH}}'
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
SCAN: '{{.SCAN}}'
build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: linux-amd64
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
cursor-tab-server:docker:amd64:
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
CONTEXT_DIR: cursor-tab-server
DOCKERFILE: cursor-tab-server/Dockerfile
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f "{{.DOCKERFILE}}"
msg: "未找到 cursor-tab-server/Dockerfile。"
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
msg: "未找到 cursor-tab-server/go.mod。"
cmds:
- mkdir -p "{{.CONTEXT_DIR}}"
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
- ls -lh "{{.IMAGE_TAR}}"
clean:dist:
internal: true
cmds:
- mkdir -p "{{.BIN_DIR}}"
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
dev:
summary: 启动开发模式(前台,终端会保持占用)
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
sources:
- package.json
- yarn.lock
generates:
- node_modules
cmds:
- yarn install --frozen-lockfile
ads:dev:
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn dev
ads:build:
summary: 构建广告 ZIP 产物
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn build
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
cmds:
- task: common:setup:docker
release:clean:
summary: 清理当前版本的发布目录
cmds:
- rm -rf "{{.RELEASE_DIR}}"
- mkdir -p "{{.RELEASE_DIR}}"
release:ensure:dir:
internal: true
cmds:
- mkdir -p "{{.RELEASE_DIR}}"
release:notes:
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
internal: true
preconditions:
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
msg: "未找到 release-notes.md,请先创建发布日志文件。"
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
msg: "release-notes.md 不能为空,请先填写发布日志。"
cmds:
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
release:build:macos:arm64:
cmds:
- task: darwin:package:archive
vars:
ARCH: arm64
BINARY_NAME: cursor-release-macos-arm64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:macos:amd64:
internal: true
cmds:
- task: darwin:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-macos-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
SCAN: '{{.SCAN}}'
release:build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-linux-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:manifest:
summary: 生成 update.json
internal: true
cmds:
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
release:prepare:
summary: 在当前主机上生成对应的发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
release:prepare:darwin:
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:clean
- task: common:update:build-assets
- task: release:notes
- task: release:build:macos:arm64
- task: release:build:macos:amd64
- task: release:build:windows:amd64
- task: release:build:linux:amd64
release:prepare:linux:
summary: 在当前主机上生成 Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:ensure:dir
- task: common:update:build-assets
- task: release:notes
- task: release:build:linux:amd64
release:verify:assets:
summary: 验证当前版本发布资产是否齐全
preconditions:
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
msg: "缺少 macOS arm64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
msg: "缺少 macOS amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
msg: "缺少 Windows amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
msg: "缺少 Linux amd64 发布资产。"
cmds:
- echo "release assets verified"
release:sync:readme:
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md
internal: true
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- |
python3 - <<'PY'
import base64
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
readme_path = pathlib.Path("README.md")
content = readme_path.read_bytes()
encoded = base64.b64encode(content).decode("ascii")
get_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"message": "docs: sync README from source repo",
"content": encoded,
"branch": "main",
}
if result.returncode == 0:
existing = json.loads(result.stdout)
existing_content = base64.b64decode(existing["content"])
if existing_content == content:
sys.exit(0)
payload["sha"] = existing["sha"]
put_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
"--method", "PUT",
"--input", "-",
]
completed = run_gh(put_cmd, input_text=json.dumps(payload))
if completed.returncode != 0 and completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
PY
release:github:
summary: 发布当前版本到 GitHub Releases
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- task: release:verify:assets
- task: release:manifest
- task: release:sync:readme
- |
python3 - <<'PY'
import mimetypes
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
version = "{{.APP_VERSION}}"
tag = f"v{version}"
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
assets = sorted([
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
])
get_cmd = [
"gh", "api",
f"repos/{repo}/releases/tags/{tag}",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": tag,
"body": notes,
"draft": False,
"prerelease": False,
}
if result.returncode == 0:
release = json.loads(result.stdout)
release_id = release["id"]
edit_cmd = [
"gh", "api",
f"repos/{repo}/releases/{release_id}",
"--method", "PATCH",
"--input", "-",
]
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
if completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
refreshed = run_gh(get_cmd)
if refreshed.returncode != 0:
if refreshed.stderr:
sys.stderr.write(refreshed.stderr)
sys.exit(refreshed.returncode)
release = json.loads(refreshed.stdout)
else:
create_cmd = [
"gh", "api",
f"repos/{repo}/releases",
"--method", "POST",
"--input", "-",
]
completed = run_gh(create_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
release = json.loads(completed.stdout)
upload_url = release["upload_url"].split("{", 1)[0]
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
for asset in assets:
name = asset.name
if name in existing:
delete_cmd = [
"gh", "api",
f"repos/{repo}/releases/assets/{existing[name]}",
"--method", "DELETE",
]
deleted = run_gh(delete_cmd)
if deleted.returncode != 0:
if deleted.stderr:
sys.stderr.write(deleted.stderr)
sys.exit(deleted.returncode)
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload_cmd = [
"gh", "api",
f"{upload_url}?name={name}",
"--method", "POST",
"--header", f"Content-Type: {content_type}",
"--input", str(asset),
]
uploaded = run_gh(upload_cmd)
if uploaded.returncode != 0:
if uploaded.stderr:
sys.stderr.write(uploaded.stderr)
sys.exit(uploaded.returncode)
PY
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User') + ';$HOME\go\bin'; task build"
pause
+2
View File
@@ -0,0 +1,2 @@
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
task build
+153
View File
@@ -0,0 +1,153 @@
version: "3"
vars:
ROOT_DIR:
sh: 'cd "{{.TASKFILE_DIR}}/.." && pwd'
BUILD_DIR:
sh: 'cd "{{.TASKFILE_DIR}}" && pwd'
tasks:
extract:proto:
summary: 从 Cursor 扩展快照提取 proto
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
cmds:
- chmod +x ./proto/extract_extensions_proto.sh
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
sync:proto:
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
dir: '{{.ROOT_DIR}}'
deps:
- task: extract:proto
cmds:
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- task: generate:proto
generate:proto:
summary: 生成 proto Go/Connect 代码
dir: '{{.ROOT_DIR}}'
sources:
- proto/*.proto
generates:
- gen/agentv1/*.go
- gen/aiserverv1/*.go
- gen/aiserverv1/aiserverv1connect/*.go
preconditions:
- sh: protoc --version
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
msg: "未检测到 protoc-gen-go,请先安装。"
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
msg: "未检测到 protoc-gen-connect-go,请先安装。"
cmds:
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
- gofmt -w $(find ./gen -name '*.go' -type f)
go:mod:tidy:
summary: 整理 Go 依赖
internal: true
dir: '{{.ROOT_DIR}}'
cmds:
- go mod tidy
install:frontend:deps:
summary: 安装前端依赖(Yarn
dir: '{{.ROOT_DIR}}/frontend'
sources:
- package.json
- yarn.lock
generates:
- node_modules
preconditions:
- sh: yarn --version
msg: "未检测到 Yarn,请先安装 Yarn。"
cmds:
- yarn install --frozen-lockfile
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: 生成前端绑定
dir: '{{.ROOT_DIR}}'
deps:
- task: generate:proto
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/*
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
build:frontend:
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
summary: 构建前端资源
dir: '{{.ROOT_DIR}}/frontend'
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
SCAN: '{{.SCAN | default "false"}}'
dev:frontend:
summary: 启动前端开发服务
dir: '{{.ROOT_DIR}}/frontend'
deps:
- task: install:frontend:deps
cmds:
- yarn run dev --port {{.VITE_PORT}} --strictPort
generate:icons:
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
dir: '{{.BUILD_DIR}}'
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
update:build-assets:
summary: 根据配置更新构建资产
dir: '{{.BUILD_DIR}}'
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
- rm -rf ios
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f ./build/docker/Dockerfile.cross
msg: "未找到 build/docker/Dockerfile.cross。"
cmds:
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+43
View File
@@ -0,0 +1,43 @@
version: "3"
# 构建元信息(修改后可执行:wails3 task common:update:build-assets
info:
companyName: "Cursor助手"
productName: "Cursor助手"
productIdentifier: "com.cursor.wuxianxubei"
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.44"
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
- "*.js"
- "*.ts"
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
fileAssociations:
other:
- name: 其他数据
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.44</string>
<key>CFBundleVersion</key>
<string>0.0.44</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.44</string>
<key>CFBundleVersion</key>
<string>0.0.44</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
+273
View File
@@ -0,0 +1,273 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 macOS 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 macOS 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
build:universal:
summary: 构建 macOS 通用包(amd64 + arm64
internal: true
deps:
- task: build
vars:
ARCH: amd64
BINARY_NAME: "{{.APP_NAME}}-amd64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
BINARY_NAME: "{{.APP_NAME}}-arm64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: 打包 macOS .app(可通过 APP_BUNDLE 指定输出包名)
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:app:bundle
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
package:dmg:
summary: 打包 macOS DMG(可通过 APP_BUNDLE/DMG_NAME 指定输出文件名)
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:dmg
vars:
ARCH:
ref: .ARCH
APP_BUNDLE:
ref: .APP_BUNDLE
DMG_NAME:
ref: .DMG_NAME
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:archive:
summary: 打包 macOS updater 归档(.tar.gz
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:universal:
summary: 打包 macOS 通用 .app
internal: true
cmds:
- task: build:universal
- task: create:app:bundle
create:app:bundle:
summary: 生成 .app 包
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp "{{.BINARY_PATH}}" "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents"
- task: codesign:adhoc
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
codesign:adhoc:
summary: 使用临时签名(开发测试)
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
cmds:
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
create:dmg:
summary: 生成 macOS DMG 包(优先使用 create-dmg,不存在时回退 hdiutil
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
DMG_NAME: '{{.DMG_NAME | default (printf "%s.dmg" .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.dmg-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp -R "{{.BIN_DIR}}/{{.APP_BUNDLE}}" "{{.STAGING_DIR}}/{{.APP_NAME}}.app"
- |
if [ -f "build/dmg-extras/提示损坏?点我.command" ]; then
cp "build/dmg-extras/提示损坏?点我.command" "{{.STAGING_DIR}}/提示损坏?点我.command"
chmod +x "{{.STAGING_DIR}}/提示损坏?点我.command"
fi
- |
if command -v create-dmg >/dev/null 2>&1; then
rm -f "{{.BIN_DIR}}/{{.DMG_NAME}}"
create-dmg --volname "{{.APP_NAME}}" --window-size 600 400 --icon-size 100 --app-drop-link 450 200 "{{.BIN_DIR}}/{{.DMG_NAME}}" "{{.STAGING_DIR}}"
else
hdiutil create -volname "{{.APP_NAME}}" -srcfolder "{{.STAGING_DIR}}" -ov -format UDZO "{{.BIN_DIR}}/{{.DMG_NAME}}"
fi
- rm -rf "{{.STAGING_DIR}}"
create:archive:
summary: 生成 macOS updater tar.gz 归档
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
cmds:
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.BIN_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.APP_BUNDLE}}"
cleanup:package:artifacts:
summary: 清理打包中间产物
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
run:
summary: 运行本地开发包
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp "{{.BIN_DIR}}/{{.BINARY_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}'
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
APP_PATH="/Applications/Cursor助手.app"
if [ ! -d "$APP_PATH" ]; then
echo "请先把 Cursor助手 拖到“应用程序”目录后再运行本脚本。"
read -r -p "按回车退出..."
exit 1
fi
echo "正在移除隔离属性: $APP_PATH"
xattr -cr "$APP_PATH"
echo "处理完成,现在可以正常打开 Cursor助手。"
read -r -p "按回车退出..."
+26
View File
@@ -0,0 +1,26 @@
# Docker image for building this app's Linux amd64 binary from macOS/Linux hosts.
ARG BASE_IMAGE=debian:trixie
FROM ${BASE_IMAGE}
ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git nodejs yarnpkg pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
&& curl -fsSL "${GO_TARBALL_URL}" -o /tmp/go.tar.gz \
&& rm -rf /usr/local/go \
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt \
&& ln -sf /usr/bin/yarnpkg /usr/local/bin/yarn \
&& rm -f /tmp/go.tar.gz \
&& rm -rf /var/lib/apt/lists/*
COPY build-linux.sh /usr/local/bin/build-linux.sh
RUN chmod +x /usr/local/bin/build-linux.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build-linux.sh"]
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
set -eu
log() {
printf '[linux-build] %s\n' "$*"
}
export GOOS=linux
export GOARCH=amd64
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
APP=${APP_NAME:-$(basename "$(pwd)")}
BIN_PATH="bin/${APP}-linux-amd64"
LOG_MODE=${GO_BUILD_LOG_MODE:-quiet}
case "$LOG_MODE" in
quiet|heartbeat)
;;
verbose|trace)
log "workspace: $(pwd)"
log "target: ${APP} (${GOOS}/${GOARCH})"
log "go version: $(go version)"
log "log mode: ${LOG_MODE}"
;;
*)
log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet"
LOG_MODE="quiet"
;;
esac
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
log "frontend/dist missing, building frontend assets in container"
(
cd frontend
yarn install --frozen-lockfile
yarn run build
)
fi
mkdir -p bin
LDFLAGS="-s -w"
if [ -n "${EXTRA_LDFLAGS:-}" ]; then
LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
fi
TAGS="production"
if [ -n "${EXTRA_TAGS:-}" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
set -- go build
case "$LOG_MODE" in
quiet)
;;
heartbeat)
;;
verbose)
set -- "$@" -v
;;
trace)
set -- "$@" -x -v
;;
esac
set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" .
if "$@"; then
:
else
status="$?"
log "go build failed with exit code ${status}"
exit "$status"
fi
case "$LOG_MODE" in
verbose|trace)
log "built: ${BIN_PATH}"
;;
esac
@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+192
View File
@@ -0,0 +1,192 @@
version: "3"
includes:
common: ../Taskfile.yml
vars:
CROSS_IMAGE: cursor-linux-amd64-cross
PROJECT_ROOT:
sh: 'cd "{{.TASKFILE_DIR}}/../.." && pwd'
tasks:
build:
summary: 构建 Linux 程序
internal: true
vars:
TARGET_ARCH: '{{.ARCH | default ARCH}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
HAS_CC:
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
preconditions:
- sh: '[ "{{.TARGET_ARCH}}" = "amd64" ]'
msg: "当前仅支持 linux/amd64 构建。"
cmds:
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 Linux 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 原生构建仅支持在 Linux 主机上执行。"
- sh: pkg-config --version >/dev/null 2>&1
msg: "未检测到 pkg-config,请先安装。"
- sh: 'command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1'
msg: "未检测到 gcc 或 clang,请先安装 C 编译器。"
- sh: pkg-config --exists gtk+-3.0 webkit2gtk-4.1 gdk-3.0 gio-unix-2.0
msg: "缺少 Linux GUI 构建依赖,请安装 gtk+-3.0、webkit2gtk-4.1、gdk-3.0 和 gio-unix-2.0 开发包。"
cmds:
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: 使用 Docker 构建 Linux 程序
internal: true
dir: '{{.PROJECT_ROOT}}'
deps:
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "Docker Linux 构建当前仅支持在 macOS 或 Linux 主机上执行。"
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: docker image inspect {{.CROSS_IMAGE}} >/dev/null 2>&1
msg: "未找到 Docker 镜像 '{{.CROSS_IMAGE}}',请先执行 task setup:docker。"
cmds:
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint /bin/sh -v "{{.PROJECT_ROOT}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e GOPATH=/go -e GOMODCACHE=/go/pkg/mod -e APP_NAME="{{.APP_NAME}}" -e EXTRA_LDFLAGS="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}" -e GO_BUILD_LOG_MODE="{{.GO_BUILD_LOG_MODE}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" /app/build/docker/build-linux.sh
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint chown -v "{{.PROJECT_ROOT}}:/app" "{{.CROSS_IMAGE}}" -R $(id -u):$(id -g) /app/bin
- mkdir -p "{{.BIN_DIR}}"
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DOCKER_PLATFORM: 'linux/{{.DOCKER_ARCH}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
GO_BUILD_LOG_MODE: '{{.GO_BUILD_LOG_MODE | default "quiet"}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
package:archive:
summary: 打包 Linux updater 归档(.tar.gz
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
OUTPUT:
ref: .OUTPUT
ARCHIVE_BINARY_NAME:
ref: .ARCHIVE_BINARY_NAME
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
ARCH:
ref: .ARCH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
create:archive:
summary: 生成 Linux updater tar.gz 归档
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default .APP_NAME}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.linux-archive-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.BINARY_PATH}}" "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- chmod +x "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.STAGING_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.ARCHIVE_BINARY_NAME}}"
- rm -rf "{{.STAGING_DIR}}"
cleanup:package:artifacts:
summary: 清理 Linux 打包中间产物
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
run:
summary: 运行 Linux 可执行文件
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}'
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 运行任务仅支持在 Linux 主机上执行。"
cmds:
- '{{.OUTPUT}}'
+13
View File
@@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Name=Cursor助手
Comment=Cursor助手
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/Cursor助手 %u
Terminal=false
Type=Application
Icon=Cursor助手
Categories=Utility;
StartupWMClass=Cursor助手
+67
View File
@@ -0,0 +1,67 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.44"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Cursor助手"
vendor: "Cursor助手"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/Cursor助手"
dst: "/usr/local/bin/Cursor助手"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Cursor助手.png"
- src: "./build/linux/Cursor助手.desktop"
dst: "/usr/share/applications/Cursor助手.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
rpm:
depends:
- gtk3
- webkit2gtk4.1
# Arch Linux packages (WebKit 4.1)
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+156
View File
@@ -0,0 +1,156 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 Windows 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用 Go 原生交叉编译构建 Windows 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- task: generate:syso
vars:
ARCH:
ref: .ARCH
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [darwin, linux]
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 0
GOARCH: '{{.ARCH | default ARCH}}'
package:
summary: 打包 Windows 安装程序
internal: true
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
generate:syso:
summary: 生成 Windows 图标与版本信息资源
internal: true
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: 生成 NSIS 安装包
internal: true
dir: build/windows/nsis
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
create:msix:package:
summary: 生成 MSIX 安装包
internal: true
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
create:zip:
summary: 生成 Windows ZIP 包(包含 exe 与 certs
internal: true
vars:
ARCH: '{{.ARCH | default ARCH}}'
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}'
STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}'
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
- rm -rf "{{.STAGING_DIR}}"
- rm -f "{{.OUTPUT}}"
run:
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
cmds:
- '{{.OUTPUT}}'
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "0.0.44"
},
"info": {
"0000": {
"ProductVersion": "0.0.44",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
"ProductName": "Cursor助手",
"Comments": "Cursor助手"
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.cursor.wuxianxubei"
Publisher="CN=Cursor助手"
Version="0.1.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="zh-cn" />
</Resources>
<Applications>
<Application Id="com.cursor.wuxianxubei" Executable="Cursor助手" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Cursor助手"
Description="本地 MITM 代理客户端,支持中转转发与托盘控制"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="Cursor助手" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="Cursor助手"
Arguments=""
InstallLocation="C:\Program Files\Cursor助手\Cursor助手">
</Installer>
<PackageInformation
PackageName="Cursor助手"
PackageDisplayName="Cursor助手"
PublisherName="CN=Cursor助手"
PublisherDisplayName="Cursor助手"
Version="0.1.0.0"
PackageDescription="Cursor助手">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.cursor.wuxianxubei"
Description="Cursor助手"
DisplayName="Cursor助手"
ExecutableName="Cursor助手"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="Cursor助手.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>
Binary file not shown.
+116
View File
@@ -0,0 +1,116 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "cursorclient"
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
SetOutPath $INSTDIR
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
+236
View File
@@ -0,0 +1,236 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Cursor助手"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.44"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.44" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
proxydebugger "cursor/cursor-proxy-debugger"
"github.com/pkg/browser"
)
func main() {
config := proxydebugger.Config{}
openBrowser := true
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := proxydebugger.New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor 协议调试代理已启动\n")
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
fmt.Printf("调试界面: %s\n", server.UIURL())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Close(shutdownContext); err != nil {
log.Printf("关闭调试代理失败:%v", err)
}
}
+60
View File
@@ -0,0 +1,60 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
## Start
Run the following command from the repository root:
```bash
go run ./cmd/cursor-proxy-debugger
```
Default addresses:
- HTTP/HTTPS proxy: `127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9091`
- MITM target: `api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
2. Open Cursor's Network settings and enable HTTP/1.1.
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
## Build
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## Options
```text
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
-target-host Host to decrypt and capture; default: api2.cursor.sh
-max-exchanges Maximum number of exchanges retained in memory; default: 200
-open Open the browser after startup; default: true
```
## Data Handling
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
- Captured traffic is stored only in process memory and is discarded when the process exits.
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
+60
View File
@@ -0,0 +1,60 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend``RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
## 启动
在仓库根目录运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认监听:
- HTTP/HTTPS 代理:`127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9091`
- MITM 目标:`api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
## 构建
```bash
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
```
## 参数
```text
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
-target-host 需要解密的目标主机,默认 api2.cursor.sh
-max-exchanges 内存中保留的最大请求数,默认 200
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 相关的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone``UploadConversationBlobs` 流量。
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
- 抓包只保留在当前进程内存中;关闭进程后消失。
- `Authorization``Cookie``Set-Cookie` 等 HTTP 头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
+88
View File
@@ -0,0 +1,88 @@
package proxydebugger
import (
"bytes"
"encoding/hex"
"io"
"sync"
)
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
buffer bytes.Buffer
limit int
size int64
truncated bool
done bool
onChunk func([]byte)
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
onChunk func([]byte),
onDone func(captured []byte, size int64, truncated bool, readErr error),
) *captureReadCloser {
return &captureReadCloser{
source: source,
limit: limit,
onChunk: onChunk,
onDone: onDone,
}
}
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
chunk := payload[:read]
reader.mu.Lock()
reader.size += int64(read)
remaining := reader.limit - reader.buffer.Len()
if remaining > 0 {
captured := read
if captured > remaining {
captured = remaining
}
_, _ = reader.buffer.Write(chunk[:captured])
}
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
reader.truncated = true
}
reader.mu.Unlock()
if reader.onChunk != nil {
reader.onChunk(append([]byte(nil), chunk...))
}
}
if err != nil {
reader.finish(err)
}
return read, err
}
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
reader.mu.Unlock()
return
}
reader.done = true
captured := append([]byte(nil), reader.buffer.Bytes()...)
size := reader.size
truncated := reader.truncated
reader.mu.Unlock()
if reader.onDone != nil {
reader.onDone(captured, size, truncated, readErr)
}
}
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
+294
View File
@@ -0,0 +1,294 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
agentprotocol "cursor/internal/backend/agent/protocol"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
const maxConnectFrameBytes = 64 << 20
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
)
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
switch path {
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, marshalErr
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", err
}
return marshalProtoJSON(message), kind, "", nil
}
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
message, kind := unaryResponseMessage(path)
if message == nil {
return "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", err
}
return marshalProtoJSON(message), kind, nil
}
func unaryRequestMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
default:
return nil, ""
}
}
func unaryResponseMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
default:
return nil, ""
}
}
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
return nil
}
}
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
+242
View File
@@ -0,0 +1,242 @@
package proxydebugger
import (
"bytes"
"compress/gzip"
"encoding/json"
"strings"
"testing"
"time"
"cursor/gen/agentv1"
"cursor/gen/aiserverv1"
"google.golang.org/protobuf/proto"
)
func TestDecodeForkTrafficRequests(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
message proto.Message
kind string
contains []string
}{
{
name: "fork background composer",
path: forkBackgroundComposerPath,
message: &aiserverv1.ForkBackgroundComposerRequest{
SourceBcId: "bc-source",
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
Name: proto.String("forked chat"),
TurnCount: proto.Uint32(4),
},
kind: "fork_background_composer_request",
contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`},
},
{
name: "notify conversation clone",
path: notifyConversationClonePath,
message: &agentv1.NotifyConversationCloneRequest{
ConversationId: "new-conversation",
SourceConversationId: "source-conversation",
SourceRequestId: "source-request",
},
kind: "notify_conversation_clone_request",
contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`},
},
{
name: "upload conversation blobs",
path: uploadConversationBlobsPath,
message: &agentv1.UploadConversationBlobsRequest{
ConversationId: "new-conversation",
Blobs: []*agentv1.BlobEntry{{
Id: []byte{1, 2},
Value: []byte("blob-value"),
}},
ChunkIndex: 1,
TotalChunks: 2,
},
kind: "upload_conversation_blobs_request",
contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(test.message)
if err != nil {
t.Fatal(err)
}
decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if kind != test.kind {
t.Fatalf("kind = %q, want %q", kind, test.kind)
}
if requestID != "" {
t.Fatalf("request ID = %q, want empty", requestID)
}
compact := compactJSON(t, decoded)
for _, expected := range test.contains {
if !strings.Contains(compact, expected) {
t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded)
}
}
})
}
}
func TestDecodeForkTrafficResponses(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
message proto.Message
kind string
contains string
}{
{
name: "fork background composer",
path: forkBackgroundComposerPath,
message: &aiserverv1.ForkBackgroundComposerResponse{
BcId: "bc-fork",
SourceBcId: "bc-source",
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
},
kind: "fork_background_composer_response",
contains: `"bc_id":"bc-fork"`,
},
{
name: "notify conversation clone",
path: notifyConversationClonePath,
message: &agentv1.NotifyConversationCloneResponse{},
kind: "notify_conversation_clone_response",
contains: `{}`,
},
{
name: "upload conversation blobs",
path: uploadConversationBlobsPath,
message: &agentv1.UploadConversationBlobsResponse{},
kind: "upload_conversation_blobs_response",
contains: `{}`,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(test.message)
if err != nil {
t.Fatal(err)
}
decoded, kind, err := decodeUnaryResponse(test.path, payload)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if kind != test.kind {
t.Fatalf("kind = %q, want %q", kind, test.kind)
}
if !strings.Contains(compactJSON(t, decoded), test.contains) {
t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded)
}
})
}
}
func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{
BcId: "bc-fork",
SourceBcId: "bc-source",
})
if err != nil {
t.Fatal(err)
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(payload); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
server := &Server{store: newExchangeStore(1)}
server.store.create(&Exchange{
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
})
server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
exchange, ok := server.store.get("1")
if !ok {
t.Fatal("exchange was not stored")
}
if exchange.ResponseKind != "fork_background_composer_response" {
t.Fatalf("response kind = %q", exchange.ResponseKind)
}
if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) {
t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON)
}
if exchange.Response.DecodeError != "" {
t.Fatalf("decode error = %q", exchange.Response.DecodeError)
}
}
func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) {
t.Parallel()
payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{
ConversationId: "new-conversation",
SourceConversationId: "source-conversation",
SourceRequestId: "source-request",
})
if err != nil {
t.Fatal(err)
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(payload); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
server := &Server{store: newExchangeStore(1)}
server.store.create(&Exchange{
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
})
server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
exchange, ok := server.store.get("1")
if !ok {
t.Fatal("exchange was not stored")
}
if exchange.RequestKind != "notify_conversation_clone_request" {
t.Fatalf("request kind = %q", exchange.RequestKind)
}
if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) {
t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON)
}
if exchange.Request.DecodeError != "" {
t.Fatalf("decode error = %q", exchange.Request.DecodeError)
}
}
func compactJSON(t *testing.T, value string) string {
t.Helper()
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(value)); err != nil {
t.Fatalf("compact JSON: %v\n%s", err, value)
}
return compact.String()
}
+447
View File
@@ -0,0 +1,447 @@
package proxydebugger
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"cursor/internal/certs"
"github.com/elazarl/goproxy"
)
type exchangeContext struct {
id string
}
// Server runs the HTTPS debugging proxy and its local web UI.
type Server struct {
config Config
certManager *certs.Manager
store *exchangeStore
counter atomic.Uint64
proxyServer *http.Server
uiServer *http.Server
proxyLn net.Listener
uiLn net.Listener
runMu sync.Mutex
}
// New creates a standalone Cursor protocol debugger.
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.UIAddr); err != nil {
return nil, err
}
manager, err := certs.NewEmbeddedManager()
if err != nil {
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
}
server := &Server{
config: config,
certManager: manager,
store: newExchangeStore(config.MaxExchanges),
}
proxyHandler, err := server.newProxyHandler()
if err != nil {
return nil, err
}
server.proxyServer = &http.Server{
Handler: proxyHandler,
ErrorLog: log.New(io.Discard, "", 0),
}
server.uiServer = &http.Server{Handler: server.newUIHandler()}
return server, nil
}
// Start starts both listeners without modifying Cursor or system proxy settings.
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.proxyLn != nil || server.uiLn != nil {
return errors.New("调试代理已经启动")
}
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
if err != nil {
return fmt.Errorf("启动代理监听失败:%w", err)
}
uiListener, err := net.Listen("tcp", server.config.UIAddr)
if err != nil {
_ = proxyListener.Close()
return fmt.Errorf("启动调试界面失败:%w", err)
}
server.proxyLn = proxyListener
server.uiLn = uiListener
go func() { _ = server.proxyServer.Serve(proxyListener) }()
go func() { _ = server.uiServer.Serve(uiListener) }()
return nil
}
// Close stops both listeners.
func (server *Server) Close(ctx context.Context) error {
server.runMu.Lock()
proxyServer := server.proxyServer
uiServer := server.uiServer
server.proxyLn = nil
server.uiLn = nil
server.runMu.Unlock()
var errorsList []error
if proxyServer != nil {
if err := proxyServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
if uiServer != nil {
if err := uiServer.Shutdown(ctx); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
func (server *Server) UIAddr() string { return server.config.UIAddr }
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
proxy := goproxy.NewProxyHttpServer()
proxy.Verbose = false
proxy.AllowHTTP2 = true
proxy.Logger = log.New(io.Discard, "", 0)
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
id := exchangeID(context)
if id == "" {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = connectionErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
proxy.Tr = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
caCertificate, err := server.certManager.CATLSCertificate()
if err != nil {
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
}
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
mitmAction := &goproxy.ConnectAction{
Action: goproxy.ConnectMitm,
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
return baseTLSConfig(host, context)
},
}
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
if server.matchesTargetHost(host) {
return mitmAction, host
}
return goproxy.OkConnect, host
})
proxy.OnRequest().DoFunc(server.captureRequest)
proxy.OnResponse().DoFunc(server.captureResponse)
return proxy, nil
}
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
return request, nil
}
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: request.URL.String(),
Host: request.URL.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: request.Header.Get("Content-Type"),
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
context.UserData = exchangeContext{id: id}
if request.Body == nil {
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
return request, nil
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"aiserver.v1.BidiRequestId",
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
},
)
return request, nil
}
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
id := exchangeID(context)
if id == "" || response == nil {
return response
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = response.Header.Get("Content-Type")
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
return response
}
var frameDecoder *connectFrameDecoder
if path == "/agent.v1.AgentService/RunSSE" {
frameDecoder = newConnectFrameDecoder(
"agent.v1.AgentServerMessage",
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
},
)
return response
}
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if decodesUnaryRequest(path) && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
func requestContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func responseContentCodec(path string, headers http.Header) string {
if path == "/agent.v1.AgentService/RunSSE" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
if decodesUnaryResponse(path) && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
if decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
if decodedJSON != "" {
exchange.Response.DecodedJSON = decodedJSON
}
if kind != "" {
exchange.ResponseKind = kind
}
if decodeErr != nil {
exchange.Response.DecodeError = decodeErr.Error()
}
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.update(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
if request == nil {
return false
}
host := request.Host
if request.URL != nil && request.URL.Host != "" {
host = request.URL.Host
}
return server.matchesTargetHost(host)
}
func (server *Server) matchesTargetHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
return host == target
}
func exchangeID(context *goproxy.ProxyCtx) string {
if context == nil {
return ""
}
value, ok := context.UserData.(exchangeContext)
if !ok {
return ""
}
return value.id
}
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试界面监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试界面只能监听本机回环地址")
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package proxydebugger
import (
"sort"
"sync"
"time"
)
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
}
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.mu.Lock()
if exchange := store.exchanges[id]; exchange != nil {
apply(exchange)
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
func (store *exchangeStore) summaries() []ExchangeSummary {
store.mu.RLock()
defer store.mu.RUnlock()
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result
}
func (store *exchangeStore) get(id string) (Exchange, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange == nil {
return Exchange{}, false
}
return cloneExchange(*exchange), true
}
func (store *exchangeStore) clear() {
store.mu.Lock()
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "cleared"})
}
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
+110
View File
@@ -0,0 +1,110 @@
package proxydebugger
import "time"
const (
defaultProxyAddr = "127.0.0.1:9090"
defaultUIAddr = "127.0.0.1:9091"
defaultTargetHost = "api2.cursor.sh"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
)
// Config controls the standalone proxy debugger.
type Config struct {
ProxyAddr string
UIAddr string
TargetHost string
MaxExchanges int
MaxCaptureBytes int
MaxFrames int
}
func (config Config) normalized() Config {
if config.ProxyAddr == "" {
config.ProxyAddr = defaultProxyAddr
}
if config.UIAddr == "" {
config.UIAddr = defaultUIAddr
}
if config.TargetHost == "" {
config.TargetHost = defaultTargetHost
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
return config
}
// ExchangeSummary is the compact request-list representation.
type ExchangeSummary struct {
ID string `json:"id"`
StartedAt time.Time `json:"startedAt"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Path string `json:"path"`
Status int `json:"status"`
State string `json:"state"`
DurationMS int64 `json:"durationMs"`
RequestBytes int64 `json:"requestBytes"`
ResponseBytes int64 `json:"responseBytes"`
RequestID string `json:"requestId,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
ResponseKind string `json:"responseKind,omitempty"`
FrameCount int `json:"frameCount"`
Error string `json:"error,omitempty"`
}
// Exchange contains the request and response detail shown by the debugger.
type Exchange struct {
ExchangeSummary
Request Payload `json:"request"`
Response Payload `json:"response"`
}
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
type Payload struct {
Headers []Header `json:"headers"`
ContentType string `json:"contentType,omitempty"`
ContentCodec string `json:"contentCodec,omitempty"`
Size int64 `json:"size"`
RawHex string `json:"rawHex,omitempty"`
RawTruncated bool `json:"rawTruncated,omitempty"`
DecodedJSON string `json:"decodedJson,omitempty"`
DecodeError string `json:"decodeError,omitempty"`
Frames []FrameView `json:"frames,omitempty"`
}
// Header is a stable, sorted HTTP header pair.
type Header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// FrameView describes one Connect streaming envelope.
type FrameView struct {
Index int `json:"index"`
Flags uint8 `json:"flags"`
Length int `json:"length"`
Compressed bool `json:"compressed"`
EndStream bool `json:"endStream"`
Kind string `json:"kind,omitempty"`
MessageType string `json:"messageType,omitempty"`
RequestID string `json:"requestId,omitempty"`
JSON string `json:"json,omitempty"`
RawHex string `json:"rawHex,omitempty"`
Error string `json:"error,omitempty"`
}
type storeEvent struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
+112
View File
@@ -0,0 +1,112 @@
package proxydebugger
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
"cursor/internal/certs"
)
//go:embed web/*
var webAssets embed.FS
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, map[string]any{
"proxyAddr": server.config.ProxyAddr,
"uiAddr": server.config.UIAddr,
"targetHost": server.config.TargetHost,
"running": true,
})
}
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.store.summaries())
}
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok := server.store.get(id)
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
}
writeJSON(writer, http.StatusOK, exchange)
}
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
server.store.clear()
writer.WriteHeader(http.StatusNoContent)
}
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
updates, unsubscribe := server.store.subscribe()
defer unsubscribe()
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-request.Context().Done():
return
case event, open := <-updates:
if !open {
return
}
payload, _ := json.Marshal(event)
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
flusher.Flush()
case <-heartbeat.C:
fmt.Fprint(writer, ": heartbeat\n\n")
flusher.Flush()
}
}
}
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
_, _ = writer.Write(certs.EmbeddedCACertPEM())
}
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
next.ServeHTTP(writer, request)
})
}
+460
View File
@@ -0,0 +1,460 @@
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
const state = {
status: null,
exchanges: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
endpoint: "all",
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "frames",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
proxyAddress: document.querySelector("#proxy-address"),
targetHost: document.querySelector("#target-host"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
endpointFilter: document.querySelector("#endpoint-filter"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("/api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
elements.targetHost.textContent = state.status.targetHost;
}
async function refreshList() {
state.exchanges = await fetchJSON("/api/exchanges");
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("/api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function isForkTrafficPath(path) {
const normalized = String(path || "").toLowerCase();
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
normalized.includes(endpoint),
);
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
elements.requestList.innerHTML = exchanges
.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
}
function renderDetailError(error) {
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(payload, tab) {
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
if (tab === "headers") return renderHeaders(payload.headers);
if (tab === "frames") return renderFrames(payload.frames);
if (tab === "raw") {
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
}
if (payload.decodedJson) {
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
}
if (payload.frames?.length) return renderFrames(payload.frames);
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function renderFrames(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
return `<div class="frame-list">${items
.map((frame) => {
const kind = frame.kind || frame.messageType || t("notices.unknown");
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
const content = frame.json
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
<summary>
<span class="frame-index">#${frame.index}</span>
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
<span class="frame-size">${formatBytes(frame.length)}</span>
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
</summary>
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
</details>`;
})
.join("")}</div>`;
}
function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
function currentCopyText(side) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
function renderPauseState() {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState();
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState();
renderList();
renderTrafficSummary();
renderDetail();
}
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("/api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
+180
View File
@@ -0,0 +1,180 @@
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "代理运行中",
"status.stopped": "代理已停止",
"actions.downloadCA": "下载代理 CA 证书",
"actions.caCertificate": "CA 证书",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
"actions.copy": "复制",
"actions.copied": "已复制",
"language.label": "界面语言",
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.fork": "Fork",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
"table.status": "状态",
"table.response": "响应",
"table.duration": "耗时",
"empty.waitingForCursor": "等待来自 Cursor 的请求",
"splitter.resize": "调整详情区域高度",
"selection.waiting": "等待选择",
"selection.prompt": "选择一条请求查看详情",
"panel.request": "请求",
"panel.response": "响应",
"panel.requestDetails": "请求详情",
"panel.responseDetails": "响应详情",
"tabs.headers": "标头",
"tabs.body": "正文",
"tabs.frames": "帧",
"tabs.raw": "原始",
"notices.noRequest": "暂无请求内容",
"notices.noResponse": "暂无响应内容",
"notices.noContent": "暂无内容",
"notices.noRaw": "暂无原始数据",
"notices.noBody": "暂无可显示的正文",
"notices.noHeaders": "暂无标头",
"notices.noFrames": "尚未收到完整帧",
"notices.unknown": "未识别",
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
"connection.live": "实时连接中",
"connection.retrying": "实时连接正在重试",
"connection.refreshFailed": "刷新失败:{message}",
"connection.paused": "界面更新已暂停",
"connection.connectFailed": "连接失败:{message}",
"state.pending": "等待中",
"state.streaming": "传输中",
"state.completed": "已完成",
"state.error": "错误",
},
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Proxy running",
"status.stopped": "Proxy stopped",
"actions.downloadCA": "Download proxy CA certificate",
"actions.caCertificate": "CA Certificate",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
"actions.copy": "Copy",
"actions.copied": "Copied",
"language.label": "Interface language",
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.fork": "Fork",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
"table.status": "Status",
"table.response": "Response",
"table.duration": "Duration",
"empty.waitingForCursor": "Waiting for requests from Cursor",
"splitter.resize": "Resize details area",
"selection.waiting": "No selection",
"selection.prompt": "Select a request to inspect its details",
"panel.request": "Request",
"panel.response": "Response",
"panel.requestDetails": "Request details",
"panel.responseDetails": "Response details",
"tabs.headers": "Headers",
"tabs.body": "Body",
"tabs.frames": "Frames",
"tabs.raw": "Raw",
"notices.noRequest": "No request content",
"notices.noResponse": "No response content",
"notices.noContent": "No content",
"notices.noRaw": "No raw data",
"notices.noBody": "No body available",
"notices.noHeaders": "No headers",
"notices.noFrames": "No complete frames received yet",
"notices.unknown": "Unknown",
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
"connection.live": "Live connection",
"connection.retrying": "Reconnecting live updates",
"connection.refreshFailed": "Refresh failed: {message}",
"connection.paused": "UI updates paused",
"connection.connectFailed": "Connection failed: {message}",
"state.pending": "Pending",
"state.streaming": "Streaming",
"state.completed": "Completed",
"state.error": "Error",
},
};
function matchLocale(locale) {
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
if (!normalized) return "";
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
if (exact) return exact;
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
}
function resolveInitialLocale() {
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
if (stored) return stored;
for (const candidate of navigator.languages || [navigator.language]) {
const matched = matchLocale(candidate);
if (matched) return matched;
}
return DEFAULT_LOCALE;
}
let currentLocale = resolveInitialLocale();
export function getLocale() {
return currentLocale;
}
export function t(key, values = {}) {
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
}
export function translateDocument(root = document) {
document.documentElement.lang = currentLocale;
document.title = t("app.title");
for (const element of root.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const [attribute, dataAttribute] of [
["aria-label", "i18nAriaLabel"],
["placeholder", "i18nPlaceholder"],
["title", "i18nTitle"],
]) {
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
}
}
}
export function setLocale(locale) {
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
window.localStorage.setItem(STORAGE_KEY, currentLocale);
translateDocument();
return currentLocale;
}
+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app" class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<strong data-i18n="app.title">Cursor 协议调试器</strong>
</div>
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="proxy-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
<option value="zh-CN">中文</option>
<option value="en-US">EN</option>
</select>
</label>
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
</header>
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
<div class="search-box url-filter">
<span aria-hidden="true"></span>
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
</div>
<div class="search-box request-id-filter">
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
</div>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
</div>
<span id="request-count" class="request-count">0 条</span>
</section>
<main id="workspace" class="workspace">
<section class="request-list-pane">
<table class="request-table">
<thead>
<tr>
<th class="status-column"></th>
<th class="index-column">#</th>
<th data-i18n="table.url">网址</th>
<th class="request-id-column">Request ID</th>
<th class="kind-column" data-i18n="table.message">消息</th>
<th class="method-column" data-i18n="table.method">方法</th>
<th class="code-column" data-i18n="table.status">状态</th>
<th class="size-column" data-i18n="table.response">响应</th>
<th class="time-column" data-i18n="table.duration">耗时</th>
</tr>
</thead>
<tbody id="request-list"></tbody>
</table>
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
</section>
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
<section id="detail-pane" class="detail-pane">
<div id="selection-summary" class="selection-summary">
<span class="method-badge">POST</span>
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
</div>
<div class="detail-columns">
<section class="payload-panel" data-side="request">
<div class="panel-header">
<strong data-i18n="panel.request">请求</strong>
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="request-content" class="panel-content"></div>
</section>
<section class="payload-panel" data-side="response">
<div class="panel-header">
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
</div>
<div id="response-content" class="panel-content"></div>
</section>
</div>
</section>
</main>
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="target-host"></span>
</footer>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+921
View File
@@ -0,0 +1,921 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#target-host {
display: none;
}
}
+2
View File
@@ -0,0 +1,2 @@
cursor-tab-server-linux-amd64.tar
*.tar
+24
View File
@@ -0,0 +1,24 @@
FROM golang:1.25 AS build
WORKDIR /src
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/cursor-tab-server .
FROM scratch
WORKDIR /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/cursor-tab-server /app/cursor-tab-server
COPY config.yaml /app/config.yaml
EXPOSE 8041
ENTRYPOINT ["/app/cursor-tab-server"]
+13
View File
@@ -0,0 +1,13 @@
### 如何获取 token
**macos**
```bash
sqlite3 "$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb" \
"SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
```
**windows 获取方式**
```bash
sqlite3 "$env:APPDATA\Cursor\User\globalStorage\state.vscdb" "SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken';"
```
+1
View File
@@ -0,0 +1 @@
token: "eyJ..."
+5
View File
@@ -0,0 +1,5 @@
module cursor-tab-server
go 1.25
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
defaultConfigPath = "./config.yaml"
defaultListenAddr = ":8041"
)
var hopByHopHeaders = map[string]struct{}{
"connection": {},
"proxy-connection": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"te": {},
"trailer": {},
"transfer-encoding": {},
"upgrade": {},
}
var defaultUpstreamTargets = map[string]string{
"/aiserver.v1.AiService/StreamCpp": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamCpp",
"/aiserver.v1.AiService/StreamNextCursorPrediction": "https://api4.cursor.sh:443/aiserver.v1.AiService/StreamNextCursorPrediction",
"/aiserver.v1.AiService/GetCppEditClassification": "https://api4.cursor.sh:443/aiserver.v1.AiService/GetCppEditClassification",
"/aiserver.v1.AiService/RefreshTabContext": "https://api2.cursor.sh:443/aiserver.v1.AiService/RefreshTabContext",
"/aiserver.v1.AiService/CppConfig": "https://api4.cursor.sh:443/aiserver.v1.AiService/CppConfig",
"/aiserver.v1.AiService/CppEditHistoryStatus": "https://api2.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryStatus",
"/aiserver.v1.AiService/CppAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppAppend",
"/aiserver.v1.AiService/CppEditHistoryAppend": "https://api3.cursor.sh:443/aiserver.v1.AiService/CppEditHistoryAppend",
"/aiserver.v1.CppService/AvailableModels": "https://api3.cursor.sh:443/aiserver.v1.CppService/AvailableModels",
"/aiserver.v1.CppService/RecordCppFate": "https://api2.cursor.sh:443/aiserver.v1.CppService/RecordCppFate",
"/aiserver.v1.AiService/ReportAiCodeChangeMetrics": "https://api2.cursor.sh:443/aiserver.v1.AiService/ReportAiCodeChangeMetrics",
"/aiserver.v1.AiService/WriteGitCommitMessage": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitCommitMessage",
"/aiserver.v1.AiService/WriteGitBranchName": "https://api2.cursor.sh:443/aiserver.v1.AiService/WriteGitBranchName",
"/aiserver.v1.FileSyncService/FSSyncFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSSyncFile",
"/aiserver.v1.FileSyncService/FSIsEnabledForUser": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSIsEnabledForUser",
"/aiserver.v1.FileSyncService/FSConfig": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSConfig",
"/aiserver.v1.FileSyncService/FSUploadFile": "https://api4.cursor.sh:443/aiserver.v1.FileSyncService/FSUploadFile",
"/aiserver.v1.DashboardService/GetEffectiveUserPlugins": "https://api2.cursor.sh:443/aiserver.v1.DashboardService/GetEffectiveUserPlugins",
}
type appConfig struct {
Token string
}
type serverApp struct {
config appConfig
client *http.Client
upstreamTargets map[string]string
}
func main() {
cfg, err := loadConfig(defaultConfigPath)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
log.Printf("cursor-tab-server 启动 listen_addr=%s config_path=%s", defaultListenAddr, defaultConfigPath)
server := &http.Server{
Addr: defaultListenAddr,
Handler: newServerApp(cfg, newHTTPClient(), defaultUpstreamTargets),
ReadHeaderTimeout: 10 * time.Second,
}
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
_, _ = fmt.Fprintf(os.Stderr, "监听失败: %v\n", err)
os.Exit(1)
}
}
func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string]string) http.Handler {
app := &serverApp{
config: cfg,
client: client,
upstreamTargets: cloneUpstreamTargets(upstreamTargets),
}
if app.client == nil {
app.client = newHTTPClient()
}
return app
}
func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if err := app.handleProxy(writer, request); err != nil {
http.Error(writer, err.Error(), http.StatusBadGateway)
}
}
func (app *serverApp) handleProxy(writer http.ResponseWriter, request *http.Request) error {
if app == nil {
return fmt.Errorf("服务实例为空")
}
rawTarget, ok := app.upstreamTargets[strings.TrimSpace(request.URL.Path)]
if !ok {
http.NotFound(writer, request)
return nil
}
targetURL, err := url.Parse(rawTarget)
if err != nil {
return fmt.Errorf("解析上游地址失败: %w", err)
}
targetURL.RawQuery = request.URL.RawQuery
requestBody := []byte{}
if shouldRequestCarryBody(request.Method) {
requestBody, err = io.ReadAll(request.Body)
if err != nil {
return fmt.Errorf("读取请求体失败: %w", err)
}
}
upstreamRequest, err := http.NewRequestWithContext(request.Context(), request.Method, targetURL.String(), bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("构建上游请求失败: %w", err)
}
copyRequestHeaders(upstreamRequest.Header, request.Header)
authorization := formatBearerAuthorization(app.config.Token)
upstreamRequest.Header.Set("Authorization", authorization)
upstreamRequest.Header.Set("x-cursor-checksum", buildCursorChecksum(authorization))
if !shouldRequestCarryBody(request.Method) {
upstreamRequest.Header.Del("content-length")
} else {
upstreamRequest.Header.Set("content-length", strconv.Itoa(len(requestBody)))
}
upstreamRequest.Host = targetURL.Host
response, err := app.client.Do(upstreamRequest)
if err != nil {
log.Printf("上游转发失败 method=%s path=%s target=%s err=%v", request.Method, request.URL.Path, targetURL.String(), err)
return fmt.Errorf("上游请求失败: %w", err)
}
defer response.Body.Close()
log.Printf("上游响应 method=%s path=%s target_host=%s status=%d", request.Method, request.URL.Path, targetURL.Host, response.StatusCode)
copyResponseHeaders(writer.Header(), response.Header)
writer.WriteHeader(response.StatusCode)
_, err = copyStream(writer, response.Body)
return err
}
func loadConfig(path string) (appConfig, error) {
contents, err := os.ReadFile(path)
if err != nil {
return appConfig{}, err
}
token, err := parseTokenYAML(contents)
if err != nil {
return appConfig{}, err
}
return appConfig{Token: token}, nil
}
func parseTokenYAML(contents []byte) (string, error) {
var cfg appConfig
if err := yaml.Unmarshal(contents, &cfg); err != nil {
return "", fmt.Errorf("解析配置失败: %w", err)
}
token := strings.TrimSpace(cfg.Token)
if token == "" {
return "", fmt.Errorf("token 不能为空")
}
return token, nil
}
func copyRequestHeaders(target http.Header, source http.Header) {
for key, values := range source {
lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists {
continue
}
for _, value := range values {
target.Add(key, value)
}
}
}
func copyResponseHeaders(target http.Header, source http.Header) {
for key, values := range source {
lowerKey := strings.ToLower(key)
if _, exists := hopByHopHeaders[lowerKey]; exists {
continue
}
for _, value := range values {
target.Add(key, value)
}
}
}
func copyStream(writer io.Writer, reader io.Reader) (int64, error) {
buffer := make([]byte, 32*1024)
var total int64
for {
readCount, readErr := reader.Read(buffer)
if readCount > 0 {
chunk := buffer[:readCount]
written, writeErr := writer.Write(chunk)
total += int64(written)
if writeErr != nil {
return total, writeErr
}
if written < len(chunk) {
return total, io.ErrShortWrite
}
if flusher, ok := writer.(http.Flusher); ok {
flusher.Flush()
}
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
return total, nil
}
return total, readErr
}
}
}
func shouldRequestCarryBody(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead, http.MethodDelete:
return false
default:
return true
}
}
func formatBearerAuthorization(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return value
}
return "Bearer " + value
}
func buildCursorChecksum(authorization string) string {
const (
checksumTimestampDivisor = 1_000_000
checksumInitialSeed = 165
)
timestamp := time.Now().UnixMilli() / checksumTimestampDivisor
timestampBytes := make([]byte, 6)
timestampBigInt := big.NewInt(timestamp)
for index := 0; index < len(timestampBytes); index++ {
shift := uint((len(timestampBytes) - 1 - index) * 8)
timestampBytes[index] = byte(new(big.Int).Rsh(timestampBigInt, shift).Uint64() & 0xff)
}
seed := checksumInitialSeed
for index := 0; index < len(timestampBytes); index++ {
current := int(timestampBytes[index]^byte(seed)) + (index % 256)
current &= 0xff
timestampBytes[index] = byte(current)
seed = current
}
prefix := strings.TrimRight(base64.StdEncoding.EncodeToString(timestampBytes), "=")
hashBytes := sha256.Sum256([]byte(strings.TrimSpace(authorization)))
hash := fmt.Sprintf("%x", hashBytes)
return prefix + hash[:32]
}
func newHTTPClient() *http.Client {
return &http.Client{}
}
func cloneUpstreamTargets(input map[string]string) map[string]string {
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}
+2
View File
@@ -0,0 +1,2 @@
registry "https://registry.npmmirror.com"
network-timeout 120000
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Cursor助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@bindings/*": ["bindings/*"]
}
},
"include": ["src/**/*"]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "node ./scripts/run-vite-build.mjs --scan --minify false --mode development",
"build": "node ./scripts/run-vite-build.mjs --scan --mode production",
"preview": "vite preview"
},
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@iconify/json": "^2.2.447",
"@wailsio/runtime": "latest",
"chart.js": "^4.5.1",
"copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify/tailwind": "^1.2.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.1.1",
"autoprefixer": "^10.5.0",
"code-inspector-plugin": "^1.4.3",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.17",
"vite": "^7.1.11",
"vite-plugin-top-level-await": "^1.6.0"
},
"browserslist": [
"Safari >= 13"
]
}
+713
View File
@@ -0,0 +1,713 @@
import { createHash } from "crypto";
import fs from "fs";
import path from "path";
import MagicString from "magic-string";
import { parse as parseJavaScript } from "@babel/parser";
import traverseModule from "@babel/traverse";
import { parse as parseTemplate } from "@vue/compiler-dom";
import { parse as parseSFC } from "@vue/compiler-sfc";
import { normalizePath } from "vite";
const traverse = traverseModule.default ?? traverseModule;
const SOURCE_LANGUAGE = "zh-CN";
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP", "ru-RU"];
const HAN_REGEX = /\p{Script=Han}/u;
const JS_HELPERS = {
localized: "__i18nLocalized",
localizedTemplate: "__i18nLocalizedTemplate",
};
const TEMPLATE_HELPERS = {
localized: "$ls",
localizedTemplate: "$lt",
};
const RUNTIME_IMPORT = "@/i18n/runtime";
const BABEL_PLUGINS = [
"jsx",
"typescript",
"classProperties",
"classPrivateProperties",
"classPrivateMethods",
"topLevelAwait",
"importAttributes",
];
function containsHan(value) {
return typeof value === "string" && HAN_REGEX.test(value);
}
function toJSONLiteral(value) {
return JSON.stringify(value);
}
function toSingleQuotedLiteral(value) {
return `'${String(value)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029")}'`;
}
function hashMessageID(message) {
return createHash("sha256").update(message).digest("hex").slice(0, 16);
}
function stripQuery(id) {
return id.split("?")[0];
}
function isSourceFile(id) {
const cleanID = stripQuery(id);
return /\.(?:js|jsx|ts|tsx|vue)$/.test(cleanID);
}
function isExcludedFile(rootDir, id) {
const cleanID = normalizePath(stripQuery(id));
if (cleanID.includes("/node_modules/")) {
return true;
}
const relativePath = normalizePath(path.relative(rootDir, cleanID));
if (!relativePath.startsWith("src/")) {
return true;
}
return relativePath.startsWith("src/i18n/");
}
function readJSONFile(filePath, fallback) {
if (!fs.existsSync(filePath)) {
return fallback;
}
const raw = fs.readFileSync(filePath, "utf8").trim();
if (!raw) {
return fallback;
}
return JSON.parse(raw);
}
function ensureDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function writeJSONFile(filePath, payload) {
ensureDirectory(filePath);
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
}
function buildRef(filePath, rootDir, loc) {
return {
file: normalizePath(path.relative(rootDir, filePath)),
line: loc?.line ?? 1,
column: loc?.column ?? 1,
};
}
function buildMessageRecord(filePath, rootDir, canonical, placeholders, loc) {
const id = hashMessageID(canonical);
return {
id,
source: canonical,
kind: placeholders > 0 ? "template" : "text",
placeholders,
ref: buildRef(filePath, rootDir, loc),
};
}
function mergeMessageRecords(records) {
const entries = new Map();
for (const record of records) {
const current = entries.get(record.id);
if (!current) {
entries.set(record.id, {
source: record.source,
kind: record.kind,
placeholders: record.placeholders,
refs: [record.ref],
});
continue;
}
if (current.source !== record.source) {
throw new Error(
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`,
);
}
current.refs.push(record.ref);
}
const sortedEntries = {};
for (const id of Array.from(entries.keys()).sort()) {
const entry = entries.get(id);
sortedEntries[id] = {
source: entry.source,
kind: entry.kind,
placeholders: entry.placeholders,
refs: entry.refs.sort((left, right) =>
left.file.localeCompare(right.file) ||
left.line - right.line ||
left.column - right.column),
};
}
return { entries: sortedEntries };
}
function mergeLocaleMessages(existingMessages, catalogEntries, locale) {
const nextMessages = {};
for (const id of Object.keys(catalogEntries)) {
if (locale === SOURCE_LANGUAGE) {
nextMessages[id] = catalogEntries[id].source;
continue;
}
const currentValue = existingMessages?.[id];
nextMessages[id] = typeof currentValue === "string" ? currentValue : "";
}
return nextMessages;
}
function walkSourceFiles(dirPath, visitor) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const nextPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") {
continue;
}
walkSourceFiles(nextPath, visitor);
continue;
}
visitor(nextPath);
}
}
function parseProgram(code, filename) {
try {
return parseJavaScript(code, {
sourceType: "module",
sourceFilename: filename,
plugins: BABEL_PLUGINS,
});
} catch (error) {
throw new Error(`[static-i18n] Failed to parse ${filename}: ${error.message}`);
}
}
function createTemplateCanonical(node) {
const parts = [];
for (let index = 0; index < node.quasis.length; index += 1) {
const quasi = node.quasis[index];
parts.push(quasi.value.cooked ?? quasi.value.raw ?? "");
if (index < node.expressions.length) {
parts.push(`{${index}}`);
}
}
return parts.join("");
}
function shouldIgnoreStringLiteral(path) {
const parent = path.parentPath;
if (!parent) {
return false;
}
if (
parent.isImportDeclaration() ||
parent.isExportAllDeclaration() ||
parent.isExportNamedDeclaration()
) {
return true;
}
if (parent.isDirective()) {
return true;
}
if (
(parent.isObjectProperty() || parent.isObjectMethod()) &&
path.key === "key" &&
parent.node.computed !== true
) {
return true;
}
if (
(parent.isMemberExpression() || parent.isOptionalMemberExpression?.()) &&
path.key === "property" &&
parent.node.computed !== true
) {
return true;
}
if (
(parent.isClassMethod?.() || parent.isClassProperty?.() || parent.isClassPrivateProperty?.()) &&
path.key === "key"
) {
return true;
}
return false;
}
function shouldIgnoreTemplateLiteral(path) {
return path.parentPath?.isTaggedTemplateExpression?.() === true;
}
function createStringLiteralReplacement(path, helperNames, record, quoteLiteral) {
if (path.parentPath?.isJSXAttribute?.() && path.key === "value") {
return `{${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})}`;
}
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
}
function createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral) {
if (path.node.expressions.length === 0) {
return `${helperNames.localized}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)})`;
}
const args = path.node.expressions.map((expression) => code.slice(expression.start, expression.end));
const payload = `[${args.join(", ")}]`;
return `${helperNames.localizedTemplate}(${quoteLiteral(record.id)}, ${quoteLiteral(record.source)}, ${payload})`;
}
function collectJSReplacements(code, ast, filePath, rootDir, helperNames, options = {}) {
const replacements = [];
const records = [];
const helperUsage = {
localized: false,
localizedTemplate: false,
};
const refLoc = options.refLoc ?? null;
const quoteLiteral = options.quoteLiteral ?? toJSONLiteral;
function resolveLoc(node) {
if (refLoc) {
return refLoc;
}
return node?.loc?.start
? {
line: node.loc.start.line,
column: node.loc.start.column + 1,
}
: { line: 1, column: 1 };
}
traverse(ast, {
noScope: true,
StringLiteral(path) {
if (shouldIgnoreStringLiteral(path) || !containsHan(path.node.value)) {
return;
}
const record = buildMessageRecord(filePath, rootDir, path.node.value, 0, resolveLoc(path.node));
records.push(record);
helperUsage.localized = true;
replacements.push({
start: path.node.start,
end: path.node.end,
text: createStringLiteralReplacement(path, helperNames, record, quoteLiteral),
});
},
TemplateLiteral(path) {
if (shouldIgnoreTemplateLiteral(path)) {
return;
}
const canonical = createTemplateCanonical(path.node);
if (!containsHan(canonical)) {
return;
}
const record = buildMessageRecord(
filePath,
rootDir,
canonical,
path.node.expressions.length,
resolveLoc(path.node),
);
records.push(record);
if (path.node.expressions.length === 0) {
helperUsage.localized = true;
} else {
helperUsage.localizedTemplate = true;
}
replacements.push({
start: path.node.start,
end: path.node.end,
text: createTemplateLiteralReplacement(path, code, helperNames, record, quoteLiteral),
});
},
});
return {
replacements,
records,
helperUsage,
};
}
function applyReplacements(code, replacements) {
if (!replacements.length) {
return null;
}
const magicString = new MagicString(code);
const sortedReplacements = [...replacements].sort((left, right) => right.start - left.start);
for (const replacement of sortedReplacements) {
magicString.overwrite(replacement.start, replacement.end, replacement.text);
}
return magicString;
}
function ensureRuntimeImport(code, helperUsage) {
if (!helperUsage.localized && !helperUsage.localizedTemplate) {
return code;
}
const pieces = [];
if (helperUsage.localized) {
pieces.push(`localized as ${JS_HELPERS.localized}`);
}
if (helperUsage.localizedTemplate) {
pieces.push(`localizedTemplate as ${JS_HELPERS.localizedTemplate}`);
}
return `import { ${pieces.join(", ")} } from "${RUNTIME_IMPORT}";\n${code}`;
}
function transformJavaScript(code, filePath, rootDir, options = {}) {
const ast = parseProgram(code, filePath);
const result = collectJSReplacements(
code,
ast,
filePath,
rootDir,
options.helperNames ?? JS_HELPERS,
{
refLoc: options.refLoc,
quoteLiteral: options.quoteLiteral,
},
);
const magicString = applyReplacements(code, result.replacements);
const transformedCode = options.injectImport === false
? magicString?.toString() ?? code
: ensureRuntimeImport(magicString?.toString() ?? code, result.helperUsage);
return {
code: transformedCode,
changed: transformedCode !== code,
records: result.records,
map: magicString
? magicString.generateMap({
source: filePath,
hires: true,
})
: null,
};
}
function translateTemplateExpression(expression, filePath, rootDir, refLoc) {
const wrappedCode = `(${expression})`;
try {
const transformed = transformJavaScript(wrappedCode, filePath, rootDir, {
helperNames: TEMPLATE_HELPERS,
injectImport: false,
refLoc,
quoteLiteral: toSingleQuotedLiteral,
});
const nextCode = transformed.code.slice(1, -1);
return {
code: nextCode,
changed: nextCode !== expression,
records: transformed.records,
};
} catch (_error) {
const transformed = transformJavaScript(expression, filePath, rootDir, {
helperNames: TEMPLATE_HELPERS,
injectImport: false,
refLoc,
quoteLiteral: toSingleQuotedLiteral,
});
return {
code: transformed.code,
changed: transformed.code !== expression,
records: transformed.records,
};
}
}
function createTextNodeReplacement(source, record) {
const trimmed = source.trim();
if (!trimmed) {
return null;
}
const leadingLength = source.indexOf(trimmed);
const leading = leadingLength > 0 ? source.slice(0, leadingLength) : "";
const trailing = source.slice(leadingLength + trimmed.length);
return `${leading}{{ $ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)}) }}${trailing}`;
}
function walkTemplateNode(node, visitor) {
visitor(node);
if (Array.isArray(node.branches)) {
node.branches.forEach((branch) => walkTemplateNode(branch, visitor));
}
if (Array.isArray(node.children)) {
node.children.forEach((child) => walkTemplateNode(child, visitor));
}
if (node.type === 1 && Array.isArray(node.props)) {
for (const prop of node.props) {
visitor(prop, node);
if (prop.exp) {
visitor(prop.exp, prop);
}
if (prop.arg) {
visitor(prop.arg, prop);
}
}
}
if (node.type === 5 && node.content) {
visitor(node.content, node);
}
}
function resolveAbsoluteLoc(sourceCode, sourceOffset, relativeOffset) {
const absoluteOffset = sourceOffset + relativeOffset;
const prefix = sourceCode.slice(0, absoluteOffset);
const lastLineBreak = prefix.lastIndexOf("\n");
return {
line: prefix.split("\n").length,
column: absoluteOffset - lastLineBreak,
};
}
function transformVueTemplate(templateCode, filePath, rootDir, options = {}) {
const ast = parseTemplate(templateCode, { comments: true });
const replacements = [];
const records = [];
const sourceCode = options.sourceCode ?? templateCode;
const sourceOffset = options.sourceOffset ?? 0;
const resolveLoc = (node, offset = 0) =>
resolveAbsoluteLoc(sourceCode, sourceOffset, node.loc.start.offset + offset);
walkTemplateNode(ast, (node, parent) => {
if (node.type === 2 && containsHan(node.content)) {
const canonical = node.content.trim();
const record = buildMessageRecord(
filePath,
rootDir,
canonical,
0,
resolveLoc(node, node.content.indexOf(canonical)),
);
const replacement = createTextNodeReplacement(node.loc.source, record);
if (!replacement) {
return;
}
records.push(record);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: replacement,
});
return;
}
if (node.type === 6 && node.value && containsHan(node.value.content)) {
const record = buildMessageRecord(
filePath,
rootDir,
node.value.content,
0,
resolveLoc(node),
);
records.push(record);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: `:${node.name}="$ls(${toSingleQuotedLiteral(record.id)}, ${toSingleQuotedLiteral(record.source)})"`,
});
return;
}
if (
node.type === 4 &&
typeof node.content === "string" &&
containsHan(node.content) &&
parent &&
((parent.type === 5) || (parent.type === 7 && parent.exp === node))
) {
const transformed = translateTemplateExpression(
node.content,
filePath,
rootDir,
resolveLoc(node),
);
if (!transformed.changed) {
return;
}
records.push(...transformed.records);
replacements.push({
start: node.loc.start.offset,
end: node.loc.end.offset,
text: transformed.code,
});
}
});
const magicString = applyReplacements(templateCode, replacements);
return {
code: magicString?.toString() ?? templateCode,
changed: Boolean(magicString),
records,
};
}
function transformVueSFC(code, filePath, rootDir) {
const { descriptor } = parseSFC(code, { filename: filePath });
const magicString = new MagicString(code);
const records = [];
let changed = false;
if (descriptor.template) {
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir, {
sourceCode: code,
sourceOffset: descriptor.template.loc.start.offset,
});
records.push(...templateResult.records);
if (templateResult.changed) {
changed = true;
magicString.overwrite(
descriptor.template.loc.start.offset,
descriptor.template.loc.end.offset,
templateResult.code,
);
}
}
for (const block of [descriptor.script, descriptor.scriptSetup].filter(Boolean)) {
const scriptResult = transformJavaScript(block.content, filePath, rootDir, {
helperNames: JS_HELPERS,
injectImport: true,
});
records.push(...scriptResult.records);
if (scriptResult.changed) {
changed = true;
magicString.overwrite(block.loc.start.offset, block.loc.end.offset, scriptResult.code);
}
}
return {
code: changed ? magicString.toString() : code,
changed,
records,
map: changed
? magicString.generateMap({
source: filePath,
hires: true,
})
: null,
};
}
function transformSourceCode(code, filePath, rootDir) {
if (filePath.endsWith(".vue")) {
return transformVueSFC(code, filePath, rootDir);
}
return transformJavaScript(code, filePath, rootDir, {
helperNames: JS_HELPERS,
injectImport: true,
});
}
function collectCatalogRecords(rootDir) {
const srcDir = path.join(rootDir, "src");
const records = [];
walkSourceFiles(srcDir, (filePath) => {
const cleanPath = normalizePath(filePath);
if (!isSourceFile(cleanPath) || isExcludedFile(rootDir, cleanPath)) {
return;
}
const code = fs.readFileSync(cleanPath, "utf8");
const result = transformSourceCode(code, cleanPath, rootDir);
records.push(...result.records);
});
return records;
}
export function syncCatalogFiles(rootDir) {
const records = collectCatalogRecords(rootDir);
const catalog = mergeMessageRecords(records);
const generatedDir = path.join(rootDir, "src/i18n/generated");
const localesDir = path.join(rootDir, "src/i18n/locales");
writeJSONFile(path.join(generatedDir, "catalog.json"), catalog);
for (const locale of SUPPORTED_LOCALES) {
const localePath = path.join(localesDir, `${locale}.json`);
const previousMessages = readJSONFile(localePath, {});
const nextMessages = mergeLocaleMessages(previousMessages, catalog.entries, locale);
writeJSONFile(localePath, nextMessages);
}
}
export function staticI18nPlugin() {
let rootDir = process.cwd();
const shouldScan = process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true";
return {
name: "cursor-static-i18n",
enforce: "pre",
configResolved(config) {
rootDir = config.root;
},
buildStart() {
if (!shouldScan) {
return;
}
syncCatalogFiles(rootDir);
},
transform(code, id) {
if (!isSourceFile(id) || isExcludedFile(rootDir, id)) {
return null;
}
const filePath = stripQuery(id);
const result = transformSourceCode(code, filePath, rootDir);
if (!result.changed) {
return null;
}
return {
code: result.code,
map: result.map,
};
},
};
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

+281
View File
@@ -0,0 +1,281 @@
:root {
--bg: #f4f5f7;
--card: #ffffff;
--text: #1f2937;
--muted: #6b7280;
--line: #e5e7eb;
--btn: #111827;
--btn-text: #ffffff;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
background: var(--bg);
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
padding: 0;
}
.app-shell {
width: 100vw;
height: 100vh;
border-radius: 10px;
background: var(--card);
overflow: hidden;
display: flex;
flex-direction: column;
}
.window-header {
height: 30px;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-bottom: 1px solid var(--line);
background: #f8f8f8;
color: #6b7280;
user-select: none;
}
body.os-windows .window-header {
justify-content: space-between;
}
.header-spacer {
display: none;
width: 48px;
height: 22px;
}
body.os-windows .header-spacer {
display: block;
}
.header-title {
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.window-actions {
display: none;
align-items: center;
gap: 4px;
}
body.os-windows .window-actions {
display: flex;
}
.window-btn {
width: 22px;
height: 22px;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.window-btn img {
width: 14px;
height: 14px;
opacity: 0.7;
}
.window-btn:hover {
background: #e5e7eb;
}
.window-btn:hover img {
opacity: 1;
}
.window-btn.close-btn:hover {
background: #ef4444;
}
.window-btn.close-btn:hover img {
filter: brightness(0) invert(1);
}
.panel {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.panel-top,
.panel-bottom {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: 74px 1fr;
align-items: center;
gap: 8px;
}
.windows-only {
display: none;
}
body.os-windows .windows-only {
display: grid;
}
.field {
display: grid;
grid-template-columns: 1fr 56px;
gap: 8px;
align-items: center;
}
.label {
color: var(--muted);
font-size: 13px;
}
input {
width: 100%;
height: 32px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 10px;
font-size: 13px;
outline: none;
background: #fff;
}
input:focus {
border-color: #9ca3af;
}
input[readonly] {
background: #f9fafb;
}
.value {
font-size: 13px;
}
.toggle-btn {
width: 100%;
height: 38px;
border: none;
border-radius: 8px;
background: var(--btn);
color: var(--btn-text);
font-size: 13px;
cursor: pointer;
}
.toggle-btn.enabled {
background: #374151;
}
.toggle-btn.waiting-init {
background: #9ca3af;
}
.init-btn {
width: 100%;
height: 38px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 13px;
cursor: pointer;
}
.init-btn:hover:not(:disabled) {
border-color: #9ca3af;
background: #f9fafb;
}
.query-btn {
height: 30px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding-left: 0;
padding-right: 0;
font-size: 12px;
color: #374151;
cursor: pointer;
}
.query-btn:disabled,
.init-btn:disabled,
.toggle-btn:disabled,
.window-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.expire-inline {
margin: 0;
font-size: 12px;
color: var(--muted);
}
.init-status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.init-status {
font-size: 12px;
}
.init-status[data-state="idle"] {
color: #6b7280;
}
.init-status[data-state="checking"] {
color: #2563eb;
}
.init-status[data-state="ready"] {
color: #047857;
}
.init-status[data-state="pending"] {
color: #92400e;
}
.init-status[data-state="error"] {
color: #b91c1c;
}
.action-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+27
View File
@@ -0,0 +1,27 @@
import { spawn } from "child_process";
import { createRequire } from "module";
import path from "path";
const require = createRequire(import.meta.url);
const vitePackagePath = require.resolve("vite/package.json");
const viteBin = path.join(path.dirname(vitePackagePath), "bin", "vite.js");
const extraArgs = process.argv.slice(2);
const shouldScan = extraArgs.includes("--scan");
const forwardedArgs = extraArgs.filter((arg) => arg !== "--scan");
const child = spawn(process.execPath, [viteBin, "build", ...forwardedArgs], {
stdio: "inherit",
env: {
...process.env,
STATIC_I18N_SCAN: shouldScan ? "true" : process.env.STATIC_I18N_SCAN || "false",
},
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
+55
View File
@@ -0,0 +1,55 @@
<template>
<MainLayout />
<MessageProvider />
<AdModelProvider v-if="isMainWindow" />
<Modal
:visible="modalState.visible"
:title="modalState.title"
:content="modalState.content"
:confirm-text="modalState.confirmText"
:cancel-text="modalState.cancelText"
:show-cancel="modalState.showCancel"
:confirm-disabled="modalState.confirmDisabled"
@confirm="resolveModal(true)"
@cancel="resolveModal(false)"
/>
<Modal
v-if="isMainWindow"
:visible="appState.updatePromptVisible"
:title="updateViewState.promptTitle"
:content="updateViewState.promptContent"
:confirm-text="updateViewState.promptConfirmText"
:cancel-text="updateViewState.promptCancelText"
:show-cancel="updateViewState.promptShowCancel"
:confirm-disabled="appState.updatePromptBusy"
@confirm="confirmUpdatePrompt"
@cancel="dismissUpdatePrompt"
/>
<InputModal
:visible="inputModalState.visible"
:title="inputModalState.title"
:content="inputModalState.content"
:placeholder="inputModalState.placeholder"
:model-value="inputModalState.value"
@update:model-value="inputModalState.value = $event"
@confirm="resolveInputModal(true)"
@cancel="resolveInputModal(false)"
/>
</template>
<script setup>
import MainLayout from "@/layouts/MainLayout.vue";
import AdModelProvider from "@/components/AdModelProvider.vue";
import Modal from "@/components/ui/Modal.vue";
import MessageProvider from "@/components/ui/MessageProvider.vue";
import { modalState, resolveModal } from "@/composables/useModal";
import InputModal from "@/components/ui/InputModal.vue";
import { inputModalState, resolveInputModal } from "@/composables/useInputModal";
import { appState, confirmUpdatePrompt, dismissUpdatePrompt, updateViewState } from "@/state/appState";
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const isMainWindow = computed(() => route.path === "/");
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+237
View File
@@ -0,0 +1,237 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { getAdRuntime, openAdExternalURL } from "@/services/clientApi";
const OPEN_AD_EVENT = "cursor:open-ad";
const BRIDGE_SOURCE = "cursor-ad";
const visible = ref(false);
const runtimeState = ref(null);
const iframeSrc = ref("");
const viewport = ref({
width: typeof window === "undefined" ? 1024 : window.innerWidth,
height: typeof window === "undefined" ? 768 : window.innerHeight,
});
const showingHashes = new Set();
let refreshPending = false;
let hideTimer = 0;
const frameStyle = computed(() => {
const win = runtimeState.value?.window ?? {};
const maxWidth = Math.max(220, viewport.value.width - 32);
const maxHeight = Math.max(160, viewport.value.height - 32);
const width = Math.min(clampNumber(win.width, 280, 1200, 640), maxWidth);
const height = Math.min(clampNumber(win.height, 180, 900, 420), maxHeight);
return {
width: `${width}px`,
height: `${height}px`,
maxWidth: "calc(100vw - 32px)",
maxHeight: "calc(100vh - 32px)",
};
});
function asString(value) {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "";
}
function asBoolean(value) {
return value === true || value === "true" || value === 1 || value === "1";
}
function asNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampNumber(value, min, max, fallback) {
const parsed = asNumber(value, fallback);
return Math.min(max, Math.max(min, parsed || fallback));
}
function normalizeRuntime(source, preferredSlotId = "") {
const raw = source && typeof source === "object" ? source : {};
const slots = Array.isArray(raw.slots) ? raw.slots : [];
const selectedSlot =
slots.find((slot) => asString(slot?.id) === asString(preferredSlotId)) ||
slots[0] ||
raw;
const slot = selectedSlot && typeof selectedSlot === "object" ? selectedSlot : {};
const win = raw.window && typeof raw.window === "object" ? raw.window : {};
const slotWin = slot.window && typeof slot.window === "object" ? slot.window : win;
return {
id: asString(slot.id) || asString(preferredSlotId) || "1",
available: asBoolean(slot.available),
enabled: asBoolean(slot.enabled),
packageHash: asString(slot.packageHash),
assetBaseURL: asString(slot.assetBaseURL).replace(/\/+$/, ""),
indexURL: asString(slot.indexURL),
window: {
width: Math.round(asNumber(slotWin.width, 640)),
height: Math.round(asNumber(slotWin.height, 420)),
},
};
}
function expectedAdOrigin() {
const baseURL = runtimeState.value?.assetBaseURL;
if (!baseURL) {
return "";
}
try {
return new URL(baseURL).origin;
} catch (_error) {
return "";
}
}
function canOpen(runtime) {
if (!runtime?.available || !runtime.enabled) {
return false;
}
return Boolean(runtime.packageHash && runtime.assetBaseURL);
}
async function openCurrentAd(slotId = "") {
if (visible.value || refreshPending) {
return;
}
refreshPending = true;
try {
const nextRuntime = normalizeRuntime(await getAdRuntime(), slotId);
runtimeState.value = nextRuntime;
if (canOpen(nextRuntime)) {
await showAd(nextRuntime);
}
} catch (_error) {
// 广
} finally {
refreshPending = false;
}
}
async function showAd(runtime) {
const hash = runtime.packageHash;
if (showingHashes.has(hash)) {
return;
}
showingHashes.add(hash);
try {
const indexURL = runtime.indexURL || `${runtime.assetBaseURL}/index.html`;
const separator = indexURL.includes("?") ? "&" : "?";
iframeSrc.value = `${indexURL}${separator}hash=${encodeURIComponent(hash)}&ts=${Date.now()}`;
visible.value = true;
} finally {
showingHashes.delete(hash);
}
}
function closeAd() {
visible.value = false;
if (hideTimer) {
window.clearTimeout(hideTimer);
}
hideTimer = window.setTimeout(() => {
iframeSrc.value = "";
}, 260);
}
function handleMessage(event) {
const origin = expectedAdOrigin();
if (origin && event.origin !== origin) {
return;
}
const data = event.data && typeof event.data === "object" ? event.data : {};
if (data.source !== BRIDGE_SOURCE) {
return;
}
if (data.type === "close") {
closeAd();
return;
}
if (data.type === "openExternal") {
const targetURL = asString(data.url);
if (targetURL) {
void openAdExternalURL(targetURL).catch(() => {});
}
}
}
function handleOpenRequested(event) {
void openCurrentAd(asString(event?.detail?.slotId));
}
function updateViewport() {
viewport.value = {
width: window.innerWidth,
height: window.innerHeight,
};
}
onMounted(() => {
window.addEventListener("message", handleMessage);
window.addEventListener(OPEN_AD_EVENT, handleOpenRequested);
window.addEventListener("resize", updateViewport);
});
onBeforeUnmount(() => {
if (hideTimer) {
window.clearTimeout(hideTimer);
}
window.removeEventListener("message", handleMessage);
window.removeEventListener(OPEN_AD_EVENT, handleOpenRequested);
window.removeEventListener("resize", updateViewport);
});
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
>
<Transition name="ad-frame">
<iframe
v-show="visible && iframeSrc"
:src="iframeSrc"
:style="frameStyle"
class="block overflow-hidden rounded-none border-none bg-transparent shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
sandbox="allow-scripts allow-forms allow-same-origin"
title="Advertisement"
/>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.ad-frame-enter-active,
.ad-frame-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.ad-frame-enter-from,
.ad-frame-leave-to {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
</style>
@@ -0,0 +1,178 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { showModal } from "@/composables/useModal";
import {
disconnectCursorAccount,
getCursorAccountStatus,
startCursorAccountLogin,
} from "@/services/clientApi";
import { toUserError } from "@/state/appState";
import { Browser } from "@wailsio/runtime";
import { computed, onMounted, onUnmounted, ref } from "vue";
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
const cursorAccountStatus = ref({
state: "signed_out",
authId: "",
email: "",
error: "",
});
const cursorAccountBusy = ref(false);
let cursorAccountTimer = null;
const cursorAccountSignedIn = computed(
() => cursorAccountStatus.value.state === "signed_in",
);
const cursorAccountWaiting = computed(
() => cursorAccountStatus.value.state === "waiting",
);
const cursorAccountStateText = computed(() => {
if (cursorAccountSignedIn.value) return "已经登录";
if (cursorAccountWaiting.value) return "等待浏览器登录";
return "未连接";
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
async function handleOpenContributor() {
try {
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
} catch (error) {
await showActionError("打开贡献者主页失败", toUserError(error));
}
}
async function refreshCursorAccountStatus() {
cursorAccountStatus.value = await getCursorAccountStatus();
}
async function handleCursorAccountLogin() {
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await startCursorAccountLogin();
} catch (error) {
await showActionError("登录失败", toUserError(error));
await refreshCursorAccountStatus().catch(() => {});
} finally {
cursorAccountBusy.value = false;
}
}
async function handleCursorAccountDisconnect() {
const confirmed = await showModal({
title: "退出登录",
content: "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
confirmText: "退出登录",
cancelText: "取消",
showCancel: true,
});
if (!confirmed) return;
cursorAccountBusy.value = true;
try {
cursorAccountStatus.value = await disconnectCursorAccount();
} catch (error) {
await showActionError("退出登录失败", toUserError(error));
} finally {
cursorAccountBusy.value = false;
}
}
onMounted(async () => {
await refreshCursorAccountStatus().catch(() => {});
cursorAccountTimer = window.setInterval(() => {
if (cursorAccountWaiting.value) {
void refreshCursorAccountStatus().catch(() => {});
}
}, 1500);
});
onUnmounted(() => {
if (cursorAccountTimer) {
window.clearInterval(cursorAccountTimer);
cursorAccountTimer = null;
}
});
</script>
<template>
<Card>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h2 class="text-base font-medium text-white">Cursor 控制面账号</h2>
<span
class="rounded-full border border-[#3a3a3a] bg-[#202020] px-2 py-0.5 text-xs text-[#b8b8b8]"
>
{{ cursorAccountStateText }}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-xs text-[#737373]">
<span>@aike0210</span>
<Tooltip>
<div class="flex min-w-[220px] flex-col gap-2">
<div>感谢 @aike0210 Cursor 控制面账号功能的贡献</div>
<button
type="button"
class="flex items-center gap-2 text-left text-[#8ab4f8] transition-colors duration-150 hover:text-[#b6d0fb]"
@click="handleOpenContributor"
>
<span class="icon-[mdi--github] text-[14px]"></span>
<span>github.com/aike0210</span>
<span class="icon-[mdi--open-in-new] text-[12px]"></span>
</button>
</div>
</Tooltip>
</div>
</div>
<div class="flex items-end justify-between gap-4">
<div class="min-w-0">
<div
v-if="cursorAccountSignedIn && (cursorAccountStatus.email || cursorAccountStatus.authId)"
class="truncate text-sm text-[#d0d0d0]"
>
{{ cursorAccountStatus.email || cursorAccountStatus.authId }}
</div>
<div class="mt-1 text-sm text-[#a3a3a3]">
独立用于插件Skills MCP不会改变 Cursor 客户端当前账号
</div>
<div v-if="cursorAccountWaiting" class="mt-1 text-sm text-[#d6a84b]">
请在浏览器完成登录完成后返回 Cursor 重新打开插件市场
</div>
<div
v-if="cursorAccountStatus.error"
class="mt-1 break-all text-sm text-[#e06c75]"
>
{{ cursorAccountStatus.error }}
</div>
</div>
<Button
v-if="cursorAccountSignedIn"
class="shrink-0"
:disabled="cursorAccountBusy"
@click="handleCursorAccountDisconnect"
>
退出登录
</Button>
<Button
v-else
class="shrink-0"
variant="primary"
:disabled="cursorAccountBusy || cursorAccountWaiting"
@click="handleCursorAccountLogin"
>
{{ cursorAccountWaiting ? "等待登录..." : "登录 Cursor" }}
</Button>
</div>
</div>
</Card>
</template>
+402
View File
@@ -0,0 +1,402 @@
<script setup>
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { appState, saveIncludeCacheWriteInHitRate } from "@/state/appState";
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
import { computed, ref } from "vue";
const emit = defineEmits(["refresh", "open-ad"]);
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
};
const props = defineProps({
metrics: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: "",
},
homeAd: {
type: Object,
default: null,
},
homeAds: {
type: Array,
default: () => [],
},
});
const homeMetricsConfigSaving = ref(false);
const homeMetricsConfigError = ref("");
function normalizeNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function formatMetricValue(value) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
return full === compact ? full : `${full} (${compact})`;
}
function formatRateLabel(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) {
return "暂无数据";
}
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
}
function calculateRate(numerator, denominator) {
const top = normalizeNumber(numerator);
const bottom = normalizeNumber(denominator);
if (bottom <= 0) {
return null;
}
return top / bottom;
}
function priceTokens(tokens, pricePerMillion) {
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
}
function formatUSD(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "$0.00";
}
if (amount > 0 && amount < 0.01) {
return "<$0.01";
}
return `$${amount.toFixed(2)}`;
}
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
const inputTokensTotal = computed(() => {
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
});
const defaultCacheHitRate = computed(() =>
calculateRate(cacheReadTokensTotal.value, cacheReadTokensTotal.value + inputTokensTotal.value),
);
const cacheReuseRate = computed(() =>
calculateRate(
cacheReadTokensTotal.value,
cacheReadTokensTotal.value + cacheWriteTokensTotal.value + inputTokensTotal.value,
),
);
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
const selectedCacheHitRate = computed(() =>
includeCacheWriteInHitRate.value ? cacheReuseRate.value : defaultCacheHitRate.value,
);
const selectedCacheRateModeLabel = computed(() =>
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
);
const validTurnsRate = computed(() => {
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
if (turnsTotal <= 0) {
return null;
}
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
});
const completionTokensTotal = computed(() => {
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, requestTokensTotal - promptTokensTotal);
});
const estimatedTokenCost = computed(() => {
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
return {
input,
output,
cacheRead,
cacheWrite,
total: input + output + cacheRead + cacheWrite,
};
});
const cacheTooltipContent = computed(() => {
const formula = includeCacheWriteInHitRate.value
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
: "缓存读取 /(缓存读取 + 非缓存输入)";
return [
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
`公式:${formula}`,
`默认 ${formatRateLabel(defaultCacheHitRate.value)} / 计入创建 ${formatRateLabel(cacheReuseRate.value)}`,
].join("\n");
});
const turnsTooltipContent = computed(() =>
[
"按历史记录里扫描到的回合 summary 汇总。",
"",
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
].join("\n"),
);
const tokensTooltipContent = computed(() =>
[
"总请求 Token 包含 Prompt 和模型输出。",
"",
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
`Prompt${formatMetricValue(props.metrics?.promptTokensTotal)}`,
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
"",
"缓存读写已计入 Prompt 侧统计。",
].join("\n"),
);
const costTooltipContent = computed(() =>
[
"按 Claude Opus 4.7 价格估算。",
`缓存统计策略:${selectedCacheRateModeLabel.value}${formatRateLabel(selectedCacheHitRate.value)}`,
"",
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
"",
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
].join("\n"),
);
function normalizeHomeAd(item, index) {
const source = item && typeof item === "object" ? item : {};
const title = typeof source.title === "string" ? source.title.trim() : "";
if (!title) {
return null;
}
return {
id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : String(index + 1),
title,
subtitle: typeof source.subtitle === "string" ? source.subtitle.trim() : "",
};
}
async function toggleIncludeCacheWriteInHitRate(value) {
const nextValue = Boolean(value);
homeMetricsConfigSaving.value = true;
homeMetricsConfigError.value = "";
try {
const result = await saveIncludeCacheWriteInHitRate(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsConfigSaving.value = false;
}
}
const normalizedHomeAds = computed(() => {
const list = Array.isArray(props.homeAds) && props.homeAds.length > 0 ? props.homeAds : [props.homeAd];
return list.map(normalizeHomeAd).filter(Boolean);
});
const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
</script>
<template>
<div>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4 h-[42px]">
<div v-if="!hasHomeAd" class="flex flex-col gap-1 w-[200px] shrink-0">
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
</div>
<div v-else class="grid min-w-0 grid-cols-3 gap-2 shrink-0">
<div
v-for="ad in normalizedHomeAds"
:key="ad.id"
style="font-family: var(--font-num)"
class="center-row h-[42px] min-w-0 cursor-pointer gap-[8px] rounded-[6px] border border-[#343434] bg-[#242424] px-[8px] pr-[10px] text-left transition-colors duration-150 hover:border-[#4a4a4a] hover:bg-[#2a2a2a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50"
role="button"
tabindex="0"
:title="ad.subtitle ? `${ad.title}\n${ad.subtitle}` : ad.title"
@click="emit('open-ad', ad.id)"
@keydown.enter.prevent="emit('open-ad', ad.id)"
@keydown.space.prevent="emit('open-ad', ad.id)"
>
<div
class="center-row h-[20px] w-[20px] shrink-0 justify-center text-[20px] text-amber-400"
>
<span class="icon-[cil--badge]"></span>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium leading-[16px] text-white">
{{ ad.title }}
</div>
<div
v-if="ad.subtitle"
class="mt-[2px] center-row min-w-0 gap-[2px] text-[11px] leading-[12px] text-[#8A8A8A]"
>
<span class="truncate">{{ ad.subtitle }}</span>
</div>
</div>
</div>
</div>
<div
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
>
<button
type="button"
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
:title="loading ? '刷新中' : '刷新统计'"
@click="emit('refresh')"
>
<span
class="icon-[mdi--refresh] text-[14px]"
:class="{ '!animate-spin': loading }"
></span>
</button>
</div>
</div>
<div
class="mt-[-4px] grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#343434] bg-[#242424] h-[130px]"
>
<div class="min-w-0 px-4 py-4 flex flex-col justify-between">
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>缓存命中率</span>
<Tooltip>
<div class="w-[280px] space-y-3">
<div class="border-b border-[#343434] pb-3">
<Switch
compact
label="计入缓存创建"
description="开启后把缓存创建纳入分母"
enabled-text="当前按复用率口径显示"
disabled-text="当前按默认命中率口径显示"
:enabled="includeCacheWriteInHitRate"
:busy="homeMetricsConfigSaving"
:disabled="homeMetricsConfigSaving"
@change="toggleIncludeCacheWriteInHitRate"
/>
</div>
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#f87171]">
{{ homeMetricsConfigError }}
</div>
</div>
</Tooltip>
</div>
<CacheHitRateChart :rate="selectedCacheHitRate" />
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>对话轮次</span>
<Tooltip :content="turnsTooltipContent" />
</div>
<div>
<div
class="text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.turnsTotal)"
>
{{ formatCompactInteger(metrics.turnsTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
有效
<span :title="formatInteger(metrics.validTurnsTotal)">
{{ formatCompactInteger(metrics.validTurnsTotal) }}
</span>
/
<span :title="formatInteger(metrics.invalidTurnsTotal)">
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>Token 消耗</span>
<Tooltip :content="tokensTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.requestTokensTotal)"
>
{{ formatCompactInteger(metrics.requestTokensTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
Prompt
<span :title="formatInteger(metrics.promptTokensTotal)">
{{ formatCompactInteger(metrics.promptTokensTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>价值估算</span>
<Tooltip :content="costTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatUSD(estimatedTokenCost.total)"
>
{{ formatUSD(estimatedTokenCost.total) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
缓存读写
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import Select from "@/components/ui/Select.vue";
import { useLocale } from "@/i18n/runtime";
const props = defineProps({
border: { type: Boolean, default: true },
ariaLabel: { type: String, default: "界面语言" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
wrapperClass: { type: String, default: "w-[180px] max-w-full" },
placeholder: { type: String, default: "选择语言" },
});
const { locale, localeOptions, setLocale } = useLocale();
</script>
<template>
<div :class="wrapperClass">
<Select
:model-value="locale"
:options="localeOptions"
:border="border"
:aria-label="ariaLabel"
:button-class="buttonClass"
:menu-class="menuClass"
:placeholder="placeholder"
@update:model-value="setLocale"
/>
</div>
</template>
@@ -0,0 +1,328 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
createEmptyModelAdapter,
normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_CUSTOM,
OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
} from "@/state/appState";
import { computed, reactive, watch } from "vue";
const modelTypeOptions = [
{ label: "openai", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const reasoningEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
{ label: "最高", value: "max", icon: "icon-[mdi--brain]" },
];
const anthropicThinkingEffortOptions = [
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
{ label: "最大", value: "max", icon: "icon-[mdi--brain]" },
];
const openAIEndpointOptions = [
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
{ label: "自定义路径", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
];
const fieldTips = {
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、prioritypriority 可用于高优先级/Fast 类场景。",
};
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "模型配置" },
adapter: {
type: Object,
default: () => createEmptyModelAdapter(),
},
errorMessage: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "save"]);
const draft = reactive(createEmptyModelAdapter());
function createOptionalPositiveIntegerModel(key) {
return computed({
get() {
return draft[key] > 0 ? String(draft[key]) : "";
},
set(value) {
const text = String(value || "").trim();
draft[key] = /^\d+$/.test(text) && Number(text) > 0 ? Number(text) : 0;
},
});
}
const maxCompletionTokensInput = createOptionalPositiveIntegerModel("maxCompletionTokens");
const anthropicMaxTokensInput = createOptionalPositiveIntegerModel("anthropicMaxTokens");
const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWindowTokens");
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureAnthropicThinkingEffort() {
if (!String(draft.anthropicThinkingEffort || "").trim()) {
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
}
}
function syncDraft() {
Object.assign(draft, normalizeModelAdapter(props.adapter));
if (!draft.type) {
draft.type = "openai";
}
}
watch(() => props.visible, (visible) => {
if (visible) {
syncDraft();
}
}, { immediate: true });
watch(() => props.adapter, () => {
if (props.visible) {
syncDraft();
}
});
watch(() => draft.type, (type) => {
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
ensureAnthropicThinkingEffort();
}
});
watch(() => draft.openAIExtraParamsEnabled, (enabled) => {
if (enabled) {
ensureOpenAIExtraParamsJSON();
}
});
function handleCancel() {
emit("cancel");
}
function handleSave() {
emit("save", normalizeModelAdapter(draft));
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="handleCancel"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[560px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-4 text-base font-medium text-white">{{ title }}</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">显示名称</span>
<input
v-model="draft.displayName"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">ModelID</span>
<input
v-model="draft.modelID"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">类型</span>
<Select
v-model="draft.type"
:options="modelTypeOptions"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">API Key</span>
<input
v-model="draft.apiKey"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
</div>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">baseURL</span>
<input
v-model="draft.baseURL"
type="text"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">context_window_tokens</span>
<input
v-model="contextWindowTokensInput"
type="text"
inputmode="numeric"
placeholder="留空时默认 200000"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<div v-if="draft.type === 'openai'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">reasoning_effort</span>
<Select
v-model="draft.reasoningEffort"
:options="reasoningEffortOptions"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">max token</span>
<input
v-model="maxCompletionTokensInput"
type="text"
inputmode="numeric"
placeholder="留空时默认 65536"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">endpoint</span>
<Select
v-model="draft.openAIEndpoint"
:options="openAIEndpointOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="mt-3 rounded-[8px] border border-[#343434] bg-[#252525] p-3">
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-sm text-[#d4d4d4]">
<Tooltip :content="fieldTips.openAIExtraParams" />
<span>额外参数 JSON</span>
</span>
<label class="flex items-center gap-2 text-xs text-[#d4d4d4]">
<input
v-model="draft.openAIExtraParamsEnabled"
type="checkbox"
class="size-4 accent-[#10AD5D]"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.openAIExtraParamsEnabled"
v-model="draft.openAIExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[6px] border border-[#3f3f3f] bg-[#1f1f1f] px-3 py-2 font-mono text-xs text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</div>
<div v-if="draft.type === 'anthropic'" class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">max_tokens</span>
<input
v-model="anthropicMaxTokensInput"
type="text"
inputmode="numeric"
placeholder="留空时默认 65536"
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">thinking effort</span>
<Select
v-model="draft.anthropicThinkingEffort"
:options="anthropicThinkingEffortOptions"
/>
</label>
</div>
<label class="mt-3 flex flex-col gap-1">
<span class="text-sm text-[#d4d4d4]">tooltipData</span>
<textarea
v-model="draft.tooltipData"
rows="5"
class="min-h-[120px] resize-none rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 py-2 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
/>
</label>
<div
v-if="errorMessage"
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
>
{{ errorMessage }}
</div>
<div class="mt-5 flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleSave">保存</Button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
@@ -0,0 +1,154 @@
<script setup>
import { computed } from "vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { formatDuration } from "@/state/appState";
const props = defineProps({
result: {
type: Object,
default: null,
},
stale: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
showMetrics: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "模型测试",
},
emptyText: {
type: String,
default: "尚未测试",
},
});
const normalizedStatus = computed(() => {
const status = String(props.result?.status || "").trim().toLowerCase();
return ["running", "success", "error"].includes(status) ? status : "idle";
});
const summaryText = computed(() => {
const text = String(props.result?.summaryText || "").trim();
if (text) {
return text;
}
if (normalizedStatus.value === "running") {
return "测试中...";
}
if (normalizedStatus.value === "error") {
return "测试失败";
}
return props.emptyText;
});
const rawResponseText = computed(() => {
const raw = String(props.result?.rawResponse || "").trim();
if (raw) {
return raw;
}
if (normalizedStatus.value === "error") {
return String(props.result?.error || "").trim();
}
return "";
});
const panelClass = computed(() => {
if (props.stale) {
return "border-[#6b5b1e] bg-[#2c2612]";
}
if (normalizedStatus.value === "running") {
return "border-[#164e63] bg-[#0b2530]";
}
if (normalizedStatus.value === "error") {
return "border-[#4b1d1d] bg-[#2a1313]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "border-[#5a4314] bg-[#2f2612]";
}
if (normalizedStatus.value === "success") {
return "border-[#14532d] bg-[#102418]";
}
return "border-[#343434] bg-[#232323]";
});
const summaryClass = computed(() => {
if (props.stale) {
return "text-[#f6d77a]";
}
if (normalizedStatus.value === "running") {
return "text-[#67e8f9]";
}
if (normalizedStatus.value === "error") {
return "text-[#fca5a5]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "text-[#fcd34d]";
}
if (normalizedStatus.value === "success") {
return "text-[#86efac]";
}
return "text-[#a3a3a3]";
});
</script>
<template>
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<div
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
>
{{ title }}
</div>
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
<span>原始返回</span>
<Tooltip :content="rawResponseText" copyable />
</div>
</div>
<div class="mt-1 text-sm leading-relaxed" :class="summaryClass">
{{ summaryText }}
</div>
</div>
<span
v-if="stale"
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
配置已变更请重新测试
</div>
<div
v-if="showMetrics && normalizedStatus === 'success'"
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
</div>
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#8f8f8f]"
>
输出 Token 为估算值
</div>
</div>
</template>
@@ -0,0 +1,128 @@
<script setup>
import {
ArcElement,
Chart as ChartJS,
Tooltip,
} from "chart.js";
import { computed } from "vue";
import { Doughnut } from "vue-chartjs";
ChartJS.register(ArcElement, Tooltip);
const props = defineProps({
rate: {
type: Number,
default: 0,
},
});
const percentage = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return 0;
}
return Math.max(0, Math.min(100, rate * 100));
});
const label = computed(() => {
const rate = Number(props.rate);
if (!Number.isFinite(rate)) {
return "--";
}
return `${percentage.value.toFixed(2)}%`;
});
function getSegmentBorderRadius(dataIndex) {
const radius = 5;
if (percentage.value <= 0) {
return dataIndex === 1
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
if (percentage.value >= 100) {
return dataIndex === 0
? {
outerStart: radius,
outerEnd: radius,
innerStart: radius,
innerEnd: radius,
}
: 0;
}
return dataIndex === 0
? {
outerStart: radius,
outerEnd: 0,
innerStart: radius,
innerEnd: 0,
}
: {
outerStart: 0,
outerEnd: radius,
innerStart: 0,
innerEnd: radius,
};
}
const chartData = computed(() => ({
labels: ["命中", "未命中"],
datasets: [
{
data: [percentage.value, Math.max(0, 100 - percentage.value)],
backgroundColor: ["#4ade80", "#373737"],
borderWidth: 0,
hoverBorderWidth: 0,
selfJoin: false,
borderRadius: ({ dataIndex }) => getSegmentBorderRadius(dataIndex),
},
],
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: "82%",
rotation: -90,
circumference: 180,
animation: {
duration: 450,
},
events: [],
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
};
</script>
<template>
<div class="flex flex-col items-center gap-3">
<div
class="relative h-[82px] w-[132px] shrink-0"
role="img"
:aria-label="`缓存命中率 ${label}`"
>
<Doughnut class="h-full w-full" :data="chartData" :options="chartOptions" />
<div class="pointer-events-none absolute inset-x-0 bottom-[10px] flex justify-center">
<div
class="text-[20px] leading-none text-white"
style="font-family: var(--font-num)"
>
{{ label }}
</div>
</div>
</div>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
defineProps({
variant: {
type: String,
default: "default",
validator: (v) => ["default", "primary", "text"].includes(v),
},
});
</script>
<template>
<button
v-if="variant === 'text'"
type="button"
class="!whitespace-nowrap shrink-0 cursor-pointer text-sm text-[#a3a3a3] transition-colors duration-150 active:text-[#10AD5D] hover:text-[#10ad5cd9]"
>
<slot />
</button>
<button
v-else
type="button"
class="!whitespace-nowrap relative cursor-pointer overflow-hidden center-row min-h-[24px] gap-[2px] rounded-[6px] text-sm transition-transform duration-150 active:scale-105"
:class="{
'bg-[linear-gradient(to_bottom,#656565_0%,#3A3A3A_10px,#3A3A3A_100%)]': variant === 'default',
'bg-gradient-to-b from-[#1D8010] to-[#25B433]': variant === 'primary',
}"
>
<span
class="relative center-row z-10 w-full justify-center rounded-[5px] !px-[7px] py-[3px] text-white transition-colors"
:class="{
'bg-gradient-to-b from-[#2a2a2a] to-[#1f1f1f] ': variant === 'default',
'font-medium bg-gradient-to-b from-[#10AD5D] to-[#0F8A4C] ': variant === 'primary',
}"
>
<slot />
</span>
</button>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup></script>
<template>
<div
class="rounded-[8px] p-[1px]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
>
<div class="rounded-[7px] bg-[#292929] p-4">
<slot />
</div>
</div>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup>
import { computed, ref, useAttrs, watch } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: { type: String, default: "" },
type: { type: String, default: "text" },
placeholder: { type: String, default: "" },
disabled: { type: Boolean, default: false },
allowVisibilityToggle: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const attrs = useAttrs();
const isPasswordVisible = ref(false);
const canToggleVisibility = computed(() => props.type === "password" && props.allowVisibilityToggle);
const inputType = computed(() => {
if (!canToggleVisibility.value) {
return props.type;
}
return isPasswordVisible.value ? "text" : "password";
});
watch(
() => [props.type, props.allowVisibilityToggle],
([type, allowVisibilityToggle]) => {
if (type !== "password" || !allowVisibilityToggle) {
isPasswordVisible.value = false;
}
},
{ immediate: true },
);
function handleInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function toggleVisibility() {
if (!canToggleVisibility.value || props.disabled) {
return;
}
isPasswordVisible.value = !isPasswordVisible.value;
}
</script>
<template>
<div class="relative w-full">
<input
v-bind="attrs"
:value="modelValue"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
class="h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none transition-colors focus:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-60"
:class="canToggleVisibility ? 'pr-10' : ''"
@input="handleInput"
/>
<button
v-if="canToggleVisibility"
type="button"
class="absolute inset-y-0 right-0 center-row px-3 text-[#8f8f8f] transition-colors hover:text-[#d4d4d4] focus:text-[#d4d4d4] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
:aria-label="isPasswordVisible ? '隐藏访问密钥' : '显示访问密钥'"
:aria-pressed="isPasswordVisible"
:disabled="disabled"
@click="toggleVisibility"
>
<span
:class="[
isPasswordVisible ? 'icon-[mdi--eye-off-outline]' : 'icon-[mdi--eye-outline]',
'text-[18px]',
]"
></span>
</button>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
placeholder: { type: String, default: "" },
modelValue: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
function onInput(event) {
emit("update:modelValue", event?.target?.value ?? "");
}
function onEnter(event) {
event.preventDefault();
handleConfirm();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
@click.self="onMaskClick"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[380px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-3 text-sm leading-relaxed text-[#a3a3a3]">
{{ content }}
</p>
<input
:value="modelValue"
:placeholder="placeholder"
type="text"
class="mb-5 h-9 w-full rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
@input="onInput"
@keydown.enter="onEnter"
/>
<div class="flex justify-end gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="primary" @click="handleConfirm">确定</Button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
@@ -0,0 +1,83 @@
<script setup>
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-[#10AD5D] text-white",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#D84C4C] text-white",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#F08A24] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#3a3a3a] text-white",
iconClass: "icon-[mingcute--loading-fill]",
iconExtraClass: "animate-spin",
},
};
function resolveTheme(type) {
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
}
</script>
<template>
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
<Transition name="message-slide" mode="out-in">
<div
v-if="messageState.current"
:key="messageState.current.id"
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
:class="resolveTheme(messageState.current.type).containerClass"
>
<span
v-if="resolveTheme(messageState.current.type).iconClass"
class="text-[14px]"
:class="[
resolveTheme(messageState.current.type).iconClass,
resolveTheme(messageState.current.type).iconExtraClass,
]"
/>
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.message-slide-enter-active,
.message-slide-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.message-slide-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.message-slide-enter-to,
.message-slide-leave-from {
opacity: 1;
transform: translateY(0);
}
.message-slide-leave-to {
opacity: 0;
transform: translateY(-12px);
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import Button from "@/components/ui/Button.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "提示" },
content: { type: String, default: "" },
confirmText: { type: String, default: "确定" },
cancelText: { type: String, default: "取消" },
showCancel: { type: Boolean, default: true },
confirmDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "confirm", "cancel"]);
function handleConfirm() {
emit("confirm");
emit("update:visible", false);
}
function handleCancel() {
emit("cancel");
emit("update:visible", false);
}
function onMaskClick() {
handleCancel();
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-mask">
<div
v-show="visible"
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4 "
@click.self="onMaskClick"
>
<Transition name="modal-content">
<div
v-show="visible"
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
@click.stop
>
<div class="rounded-[7px] bg-[#292929] p-5">
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-5 max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-[#a3a3a3]">
{{ content }}
</p>
<div class="flex justify-end gap-2">
<Button v-if="showCancel" variant="default" @click="handleCancel">{{ cancelText }}</Button>
<Button variant="primary" :disabled="confirmDisabled" @click="handleConfirm">{{ confirmText }}</Button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask-enter-active,
.modal-mask-leave-active {
transition: opacity 0.25s ease, backdrop-filter 0.25s ease;
}
.modal-mask-enter-from,
.modal-mask-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-content-enter-active,
.modal-content-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-content-enter-from,
.modal-content-leave-to {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
</style>
+343
View File
@@ -0,0 +1,343 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
const props = defineProps({
modelValue: { type: String, default: "" },
options: {
type: Array,
default: () => [],
},
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
border: { type: Boolean, default: true },
ariaLabel: { type: String, default: "" },
buttonClass: { type: String, default: "" },
menuClass: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "change", "blur"]);
const rootRef = ref(null);
const buttonRef = ref(null);
const menuRef = ref(null);
const optionRefs = ref([]);
const isOpen = ref(false);
const activeIndex = ref(-1);
const menuStyle = ref({});
const normalizedOptions = computed(() => props.options.map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
}
return {
label: option?.label ?? option?.value ?? "",
value: option?.value ?? "",
icon: option?.icon ?? option?.iconClass ?? "",
};
}));
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
function setOptionRef(el, index) {
if (el) {
optionRefs.value[index] = el;
return;
}
delete optionRefs.value[index];
}
function focusActiveOption() {
nextTick(() => {
const option = optionRefs.value[activeIndex.value];
option?.focus();
});
}
function openMenu() {
if (props.disabled || isOpen.value) {
return;
}
isOpen.value = true;
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
nextTick(() => {
updatePosition();
focusActiveOption();
});
}
function closeMenu({ restoreFocus = false } = {}) {
if (!isOpen.value) {
return;
}
isOpen.value = false;
activeIndex.value = -1;
optionRefs.value = [];
menuStyle.value = {};
if (restoreFocus) {
nextTick(() => buttonRef.value?.focus());
}
emit("blur");
}
function toggleMenu() {
if (isOpen.value) {
closeMenu();
return;
}
openMenu();
}
function selectOption(option) {
if (!option || option.value === props.modelValue) {
closeMenu({ restoreFocus: true });
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
closeMenu({ restoreFocus: true });
}
function moveActiveIndex(step) {
if (!normalizedOptions.value.length) {
return;
}
if (!isOpen.value) {
openMenu();
return;
}
const total = normalizedOptions.value.length;
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
activeIndex.value = (current + step + total) % total;
focusActiveOption();
}
function handleButtonKeydown(event) {
if (props.disabled) {
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
toggleMenu();
break;
case "Escape":
if (isOpen.value) {
event.preventDefault();
closeMenu();
}
break;
default:
break;
}
}
function handleOptionKeydown(event, option, index) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(1);
break;
case "ArrowUp":
event.preventDefault();
activeIndex.value = index;
moveActiveIndex(-1);
break;
case "Enter":
case " ":
event.preventDefault();
selectOption(option);
break;
case "Escape":
event.preventDefault();
closeMenu({ restoreFocus: true });
break;
case "Tab":
closeMenu();
break;
default:
break;
}
}
function handlePointerDown(event) {
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
return;
}
closeMenu();
}
function updatePosition() {
if (!buttonRef.value || !menuRef.value) {
return;
}
computePosition(buttonRef.value, menuRef.value, {
placement: "bottom-start",
middleware: [
offset(6),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
apply({ rects, elements, availableHeight }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
maxHeight: `${Math.max(availableHeight, 160)}px`,
});
},
padding: 12,
}),
],
}).then(({ x, y }) => {
menuStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
return;
}
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
cleanup(() => {
stopAutoUpdate();
});
});
watch(() => props.modelValue, () => {
if (!isOpen.value) {
return;
}
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
});
watch(isOpen, (open) => {
if (open) {
document.addEventListener("pointerdown", handlePointerDown);
return;
}
document.removeEventListener("pointerdown", handlePointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", handlePointerDown);
});
</script>
<template>
<div ref="rootRef" class="relative">
<button
ref="buttonRef"
type="button"
:disabled="disabled"
class="flex h-9 items-center rounded-[6px] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
border
? 'w-full justify-between gap-2 border border-[#3f3f3f] focus:border-[#10AD5D]'
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35',
buttonClass,
]"
:aria-expanded="isOpen"
:aria-label="ariaLabel || undefined"
aria-haspopup="listbox"
@click="toggleMenu"
@keydown="handleButtonKeydown"
>
<span
class="flex min-w-0 items-center gap-2"
:class="[
border ? 'flex-1' : 'shrink-0',
selectedOption
? (border ? 'text-[#e5e5e5]' : 'text-current')
: 'text-[#7b7b7b]',
]"
>
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ selectedLabel }}</span>
</span>
<span
class="pointer-events-none center-row transition-transform duration-200"
:class="[border ? 'text-[#8f8f8f]' : 'text-current', isOpen ? 'rotate-180' : '']"
>
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
</span>
</button>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
:class="menuClass"
:style="menuStyle"
>
<ul role="listbox" class="overflow-y-auto py-1">
<li v-for="(option, index) in normalizedOptions" :key="option.value">
<button
:ref="(el) => setOptionRef(el, index)"
type="button"
role="option"
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
:class="[
option.value === modelValue
? 'bg-[#10AD5D]/15 text-[#10d06f]'
: 'text-[#e5e5e5] hover:bg-[#303030]',
activeIndex === index ? 'bg-[#303030]' : '',
]"
:aria-selected="option.value === modelValue"
tabindex="0"
@click="selectOption(option)"
@mouseenter="activeIndex = index"
@keydown="handleOptionKeydown($event, option, index)"
>
<span class="flex min-w-0 items-center gap-2">
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ option.label }}</span>
</span>
</button>
</li>
</ul>
</div>
</Transition>
</Teleport>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup>
const props = defineProps({
enabled: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
label: { type: String, default: "" },
description: { type: String, default: "" },
enabledText: { type: String, default: "已开启" },
disabledText: { type: String, default: "已关闭" },
busyText: { type: String, default: "切换中..." },
});
const emit = defineEmits(["change"]);
function handleToggle() {
if (props.disabled || props.busy) {
return;
}
emit("change", !props.enabled);
}
</script>
<template>
<div
class="flex items-center justify-between gap-4"
:class="compact ? 'py-0' : 'py-1'"
>
<div class="flex min-w-0 flex-col" :class="compact ? 'gap-[2px]' : 'gap-1'">
<div :class="compact ? 'text-[12px]' : 'text-sm'" class="font-medium text-white">
{{ label }}
</div>
<div
v-if="description"
:class="compact ? 'text-[11px] leading-[16px]' : 'text-xs'"
class="text-[#a3a3a3]"
>
{{ description }}
</div>
<div
:class="[
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
enabled ? 'text-[#10AD5D]' : 'text-[#a3a3a3]',
]"
>
{{ busy ? busyText : enabled ? enabledText : disabledText }}
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="enabled"
:disabled="disabled || busy"
class="relative inline-flex h-[22px] w-[40px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-55 focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35"
:class="enabled ? 'bg-[#10AD5D]' : 'bg-[rgba(255,255,255,0.22)]'"
@click="handleToggle"
>
<span
class="absolute left-[2px] top-[2px] inline-flex h-[18px] w-[18px] rounded-full bg-white shadow-[0_2px_5px_rgba(0,0,0,0.22)] transition-all duration-200 ease-out"
:class="enabled ? 'translate-x-[18px]' : 'translate-x-0'"
/>
</button>
</div>
</template>

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