chore: import Local-Cursor v1.1.8 source snapshot

Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage.
Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
This commit is contained in:
FuDie0915
2026-11-10 01:24:28 +08:00
commit db73cf5a39
337 changed files with 147502 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
*
!go.mod
!go.sum
!cursor-tab-server/
!cursor-tab-server/**
!gen/
!gen/**
!internal/
!internal/**

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
claude-server.tar
dist
.task
bin
logs/
gen/
*.syso
logs.zip
frontend/bindings
.cursor/
frontend/dist
bin/release
dist
node_modules
cursor-server.tar
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
.cursor-local-assistant
.cursor-local-assistant-v2
.cursor-app-formatted/
ads-server-linux-amd64.tar
cmd/ads-server/*.db
cmd/ads-server/*.db-*
cmd/ads-server/*.sqlite
cmd/ads-server/*.sqlite-*
cmd/ads-server/data/
cmd/ads-server/ads-server
cmd/ads-server/ads-server-linux-amd64.tar
cursor-tab-server/cursor-tab-server-linux-amd64.tar
build/build/

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 leookun
Copyright (c) 2026 FuDie0915
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.

173
README.md Normal file
View File

@@ -0,0 +1,173 @@
# Local-Cursor
本地运行的 Cursor IDE 代理工具。通过 MITM 代理劫持 Cursor 的 AI 请求,转发到自定义模型 API(OpenAI / Anthropic 兼容),支持本地助手模式与直连上游模式;同时适配 CLI 版 cursor-agent,并提供 Skill 分发、一键汉化、使用量统计等增强功能。
> 基于 [cursor-byok](https://github.com/leookun/cursor-byok.git) 衍生,感谢原作者。本项目仅供学习与个人使用,请支持正版 Cursor。
## 功能
| 功能 | 说明 |
|------|------|
| 请求劫持与转发 | MITM 代理劫持 Cursor AI 请求,转发到自定义 API |
| 多模型渠道 | 支持同时配置多个 OpenAI / Anthropic 兼容渠道 |
| 模型批量拉取 | 通过 `/v1/models` 端点拉取可用模型列表,勾选后批量添加渠道 |
| Skill 管理 | 内置 Skill(含斜杠命令)分发到任意打开过的 Cursor 项目,开关即装/卸 |
| Cursor 状态面板 | 自动检测 Cursor 安装目录与运行状态,5 秒心跳刷新,支持手动指定路径 |
| 一键汉化 | 自动检测安装目录,运行社区汉化包(自动适配 fnm/nvm 等 Node 环境) |
| Tab 补全 | 可配置上游 Tab 补全服务地址(可选 Docker 部署) |
| 模型测速 | 内置模型渠道延迟/吞吐量基准测试 |
| 会话统计 | 使用量持久化与聚合统计,支持定时刷新与累计/本次运行两种口径 |
| CA 证书管理 | 自动注入 CA 证书到系统信任链 |
| Cursor 设置注入 | 自动写入代理、HTTP/2、证书配置,并写入 `update.mode: none` 禁用自动更新 |
| CLI 代理适配 | 自动检测 CLI 版 cursor-agent,生成 wrapper + preload,重定向 TCP 连接 |
| 快速启动 | 运行时自动注册开始菜单快捷方式,Flow Launcher / PowerToys Run 可直接检索 |
| 网络代理感知 | 自动检测系统/环境变量代理并适配出站连接 |
## 架构
```
┌──────────────────────────────────────────────────┐
│ Cursor IDE │
│ (settings.json 注入 http.proxy) │
└────────────┬─────────────────────────────────────┘
│ HTTPS CONNECT
▼
┌──────────────────────┐ 白名单域名(.cursor.sh)
│ MITM Proxy Server │ ────────────────► ┌──────────────────┐
│ (127.0.0.1:28080) │ │ Backend Server │
│ │ ◄──────────────── │ (127.0.0.1:28090)│
│ 非白名单域名直连回源 │ 转发+路由 └────────┬─────────┘
└──────────────────────┘ │
┌────────┴────────┐
│ Forwarder │
│ BidiAppend/SSE │
│ Prompt 编译 │
│ Provider 调用 │
└────────┬────────┘
│
┌────────┴────────┐
│ Custom API │
│ (OpenAI/Anthropic)│
└─────────────────┘
┌──────────────────────────────────────────────────┐
│ CLI 版 cursor-agent │
│ (agent.cmd wrapper + tls-bypass.cjs preload) │
└────────────┬─────────────────────────────────────┘
│ TCP 重定向 (patch net.connect/tls.connect)
▼
┌──────────────────────┐
│ CLI HTTPS Listener │
│ (127.0.0.1:39092) │ ← HTTPS+HTTP/2,跳过 MITM
│ 复用 Backend 路由 │
└──────────────────────┘
```
**IDE 模式**:Cursor 通过注入的 `http.proxy` 将请求发往本地 MITM 代理(`:28080`),白名单域名解密后转发到本地后端(`:28090`),按 `routing.mode` 选择本地处理或上游转发。
**CLI 模式**:CLI 的 connect-node 使用 HTTP/2,无法被 goproxy MITM,因此通过 Node preload 在 TCP 层把 `api2/api3.cursor.sh` 重定向到本地 HTTPS+HTTP/2 监听器(`:39092`),复用同一后端路由。
## 默认端口
| 服务 | 地址 | 说明 |
|------|------|------|
| Backend Server | `127.0.0.1:28090` | 嵌入式 HTTP 后端 |
| MITM Proxy | `127.0.0.1:28080` | Cursor 流量劫持代理 |
| CLI HTTPS | `127.0.0.1:39092` | CLI 专用 HTTPS+HTTP/2 监听器 |
| Tab 补全 | `18041`(可选) | Docker 部署的 Tab 补全服务 |
## 安装与使用
1. 从 [Releases](../../releases) 下载对应平台分发包并安装/解压。
2. 启动后点击「开启服务」,程序自动完成 CA 证书注入、Cursor settings.json 注入与 CLI wrapper 生成。
3. 在「模型配置」中添加渠道,或使用「拉取模型」从 API 端点批量导入。
4. 配置完成后正常使用 Cursor / cursor-agent 即可,流量将走你配置的模型。
程序运行时会自动注册开始菜单快捷方式,Flow Launcher、PowerToys Run 等启动器可直接检索 `Local-Cursor`。
## 配置
首次启动后自动生成 `~/.cursor-local-assistant-v2/config.yaml`,关键字段:
```yaml
providerStreamIdleTimeout: 240
backendListenAddr: "127.0.0.1:28090"
proxyListenAddr: "127.0.0.1:28080"
cliHttpsListenAddr: "127.0.0.1:39092"
modelAdapters:
- displayName: "My Model"
type: "openai" # openai | anthropic
baseURL: "https://api.example.com"
apiKey: "sk-xxx"
modelID: "gpt-4"
reasoningEffort: "medium" # low | medium | high | xhigh
openAIEndpoint: "/v1/responses" # /v1/responses | /v1/chat/completions
contextWindowTokens: 500000 # 留空使用默认 500K
maxCompletionTokens: 278000 # 留空使用默认 278K
routing:
mode: "local" # local | upstream
homeMetrics:
includeCacheWriteInHitRate: false
refreshIntervalSeconds: 60 # 统计定时刷新间隔(秒,最小 5)
persistentMode: true # 累计统计
tabServerBaseURL: ""
tabServerToken: ""
```
## 构建与开发
### 前置依赖
- Go 1.25+
- Node.js 20+(含 yarn,可用 corepack)
- [Wails v3 CLI](https://wails.io/):`go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
- protoc + protoc-gen-go + protoc-gen-connect-go(生成 `gen/`,运行 `task sync:proto` 或手动执行 `build/Taskfile.yml` 中的 generate:proto)
### 常用命令
```bash
# 前端依赖
corepack yarn install --cwd frontend
# 生成 Wails 前端绑定(修改 service 后需要)
wails3 generate bindings -clean -d frontend/bindings
# 构建当前平台分发包
task build
# 开发模式
task dev
```
## 数据目录
所有数据存储在 `~/.cursor-local-assistant-v2/`:
```
~/.cursor-local-assistant-v2/
├── config.yaml # 用户配置
├── data/
│ ├── ca.crt # 注入 CA 证书
│ ├── codebase-index/ # 代码库索引
│ └── docs-index/ # 文档索引
├── history/
│ ├── usage.json # 使用量聚合统计
│ └── <conversation_id>/ # 会话状态
├── logs/ # 运行日志
├── cli/ # CLI wrapper 与 preload(自动生成)
└── rules/ # 用户规则
```
## 免责声明
本项目通过本地代理方式修改 Cursor 的请求路由,仅供学习研究。请购买正版 Cursor 订阅,因使用本项目产生的任何问题与原作者及本项目维护者无关。
## License
[MIT](LICENSE)
## 致谢
- [cursor-byok](https://github.com/leookun/cursor-byok.git) — 原项目
- [Wails v3](https://wails.io/) — 桌面框架
- [@polang233/cursor-language-pack](https://www.npmjs.com/package/@polang233/cursor-language-pack) — Cursor 汉化包

566
Taskfile.yml Normal file
View File

@@ -0,0 +1,566 @@
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: "Local-Cursor"
BIN_DIR: "bin"
APP_VERSION:
sh: 'go run ./scripts/release version -config ./build/config.yml'
RELEASE_REPO: ""
RELEASE_BASE_NAME: "Local-Cursor"
RELEASE_SOURCE_PATH: "release-notes.md"
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
WINDOWS_RELEASE_DIR: '{{.BIN_DIR}}/release/v{{.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:windows:installer:
summary: 构建 Windows NSIS 安装包(含卸载程序)
cmds:
- task: clean:dist
- task: windows:create:nsis:installer
vars:
ARCH: amd64
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}}
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,请先创建发布日志文件。"
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" ] || [ "{{OS}}" = "windows" ]'
msg: "release:prepare 仅支持在 macOS、Linux 或 Windows 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else if eq OS "linux"}}release:prepare:linux{{else}}release:prepare:windows{{end}}'
release:prepare:windows:
summary: 在 Windows 上生成约定结构的发布资产
preconditions:
- sh: '[ "{{OS}}" = "windows" ]'
msg: "release:prepare:windows 仅支持在 Windows 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- mkdir -p "{{.WINDOWS_RELEASE_DIR}}"
- task: common:update:build-assets
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.WINDOWS_RELEASE_DIR}}/Local-Cursor.exe'
ZIP_NAME: 'release/v{{.APP_VERSION}}/local-cursor-v{{.APP_VERSION}}-windows-x64-portable.zip'
ARCHIVE_BINARY_NAME: windows-64.exe
KEEP_OUTPUT: "true"
SCAN: '{{.SCAN}}'
- task: windows:create:nsis:installer
vars:
ARCH: amd64
OUTPUT: '{{.WINDOWS_RELEASE_DIR}}/Local-Cursor.exe'
INSTALLER_OUTPUT: '{{.WINDOWS_RELEASE_DIR}}/Local-Cursor-amd64-installer.exe'
SCAN: '{{.SCAN}}'
- powershell -NoProfile -Command "Compress-Archive -LiteralPath '{{.WINDOWS_RELEASE_DIR}}/Local-Cursor-amd64-installer.exe' -DestinationPath '{{.WINDOWS_RELEASE_DIR}}/local-cursor-v{{.APP_VERSION}}-installer.zip' -Force -ErrorAction Stop"
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

153
build/Taskfile.yml Normal file
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 ./proto/extensions-cursor-app/cursor-always-local/dist/main.js || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
msg: "未找到 Cursor 扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
cmds:
- chmod +x ./proto/extract_extensions_proto.sh
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
sync:proto:
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
dir: '{{.ROOT_DIR}}'
deps:
- task: extract:proto
cmds:
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- task: generate:proto
generate:proto:
summary: 生成 proto Go/Connect 代码
dir: '{{.ROOT_DIR}}'
sources:
- proto/*.proto
generates:
- gen/agentv1/*.go
- gen/aiserverv1/*.go
- gen/aiserverv1/aiserverv1connect/*.go
preconditions:
- sh: protoc --version
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
msg: "未检测到 protoc-gen-go,请先安装。"
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
msg: "未检测到 protoc-gen-connect-go,请先安装。"
cmds:
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
- gofmt -w $(find ./gen -name '*.go' -type f)
go:mod:tidy:
summary: 整理 Go 依赖
internal: true
dir: '{{.ROOT_DIR}}'
cmds:
- go mod tidy
install:frontend:deps:
summary: 安装前端依赖(Yarn)
dir: '{{.ROOT_DIR}}/frontend'
sources:
- package.json
- yarn.lock
generates:
- node_modules
preconditions:
- sh: yarn --version
msg: "未检测到 Yarn,请先安装 Yarn。"
cmds:
- yarn install --frozen-lockfile
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: 生成前端绑定
dir: '{{.ROOT_DIR}}'
deps:
- task: generate:proto
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/*
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
build:frontend:
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
summary: 构建前端资源
dir: '{{.ROOT_DIR}}/frontend'
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
SCAN: '{{.SCAN | default "false"}}'
dev:frontend:
summary: 启动前端开发服务
dir: '{{.ROOT_DIR}}/frontend'
deps:
- task: install:frontend:deps
cmds:
- yarn run dev --port {{.VITE_PORT}} --strictPort
generate:icons:
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
dir: '{{.BUILD_DIR}}'
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
update:build-assets:
summary: 根据配置更新构建资产
dir: '{{.BUILD_DIR}}'
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
- rm -rf ios
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f ./build/docker/Dockerfile.cross
msg: "未找到 build/docker/Dockerfile.cross。"
cmds:
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

43
build/config.yml Normal file
View File

@@ -0,0 +1,43 @@
version: "3"
# 构建元信息(修改后可执行:wails3 task common:update:build-assets)
info:
companyName: "Local-Cursor"
productName: "Local-Cursor"
productIdentifier: "com.Local-Cursor"
description: "Local-Cursor"
copyright: "© 2026, Local-Cursor"
comments: "Local-Cursor"
version: "1.1.8"
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: 其他数据

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>Local-Cursor</string>
<key>CFBundleGetInfoString</key>
<string>Local-Cursor</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.Local-Cursor</string>
<key>CFBundleName</key>
<string>Local-Cursor</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.8</string>
<key>CFBundleVersion</key>
<string>1.1.8</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.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, Local-Cursor</string>
</dict>
</plist>

32
build/darwin/Info.plist Normal file
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>Local-Cursor</string>
<key>CFBundleGetInfoString</key>
<string>Local-Cursor</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.Local-Cursor</string>
<key>CFBundleName</key>
<string>Local-Cursor</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1.8</string>
<key>CFBundleVersion</key>
<string>1.1.8</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Local-Cursor</string>
</dict>
</plist>

273
build/darwin/Taskfile.yml Normal file
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}}'

BIN
build/darwin/icons.icns Normal file

Binary file not shown.

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 "按回车退出..."

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"]

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

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

192
build/linux/Taskfile.yml Normal file
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
build/linux/desktop Normal file
View File

@@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Name=Local-Cursor
Comment=Local-Cursor
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/Local-Cursor %u
Terminal=false
Type=Application
Icon=Local-Cursor
Categories=Utility;
StartupWMClass=Local-Cursor

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: "Local-Cursor"
arch: ${GOARCH}
platform: "linux"
version: "1.1.8"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Local-Cursor"
vendor: "Local-Cursor"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/Local-Cursor"
dst: "/usr/local/bin/Local-Cursor"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Local-Cursor.png"
- src: "./build/linux/Local-Cursor.desktop"
dst: "/usr/share/applications/Local-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
build/tray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

169
build/windows/Taskfile.yml Normal file
View File

@@ -0,0 +1,169 @@
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
deps:
- task: build
vars:
OUTPUT:
ref: .OUTPUT
SCAN:
ref: .SCAN
cmds:
- wails3 generate webview2bootstrapper -dir "build/windows/nsis"
- |
{{if eq OS "windows"}}
powershell -NoProfile -Command '$binary = (Resolve-Path "{{.OUTPUT}}").Path; Set-Location "build/windows/nsis"; makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="$binary" project.nsi'
{{else}}
binary="$(cd "$(dirname "{{.OUTPUT}}")" && pwd)/$(basename "{{.OUTPUT}}")"; cd build/windows/nsis && makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="$binary" project.nsi
{{end}}
- |
{{if .INSTALLER_OUTPUT}}
mv "{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}-installer.exe" "{{.INSTALLER_OUTPUT}}"
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
INSTALLER_OUTPUT: '{{.INSTALLER_OUTPUT | default ""}}'
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)}}'
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default (base .OUTPUT)}}'
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}}/{{.ARCHIVE_BINARY_NAME}}"
- |
{{if eq OS "windows"}}
powershell -NoProfile -Command "Compress-Archive -Path '{{.STAGING_DIR}}/*' -DestinationPath '{{.BIN_DIR}}/{{.ZIP_NAME}}' -Force -ErrorAction Stop"
{{else}}
(cd "{{.STAGING_DIR}}" && zip -qr "../{{.ZIP_NAME}}" .)
{{end}}
- rm -rf "{{.STAGING_DIR}}"
- '{{if ne .KEEP_OUTPUT "true"}}rm -f "{{.OUTPUT}}"{{end}}'
run:
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
cmds:
- '{{.OUTPUT}}'

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

17
build/windows/info.json Normal file
View File

@@ -0,0 +1,17 @@
{
"fixed": {
"file_version": "1.1.8",
"product_version": "1.1.8"
},
"info": {
"0409": {
"FileVersion": "1.1.8",
"ProductVersion": "1.1.8",
"CompanyName": "Local-Cursor",
"FileDescription": "Local-Cursor",
"LegalCopyright": "© 2026, Local-Cursor",
"ProductName": "Local-Cursor",
"Comments": "Local-Cursor"
}
}
}

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>

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.

View File

@@ -0,0 +1,120 @@
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
# Remove user data and local params (config.yaml, metrics, history, CLI wrapper, CA cert)
RMDir /r "$PROFILE\.cursor-local-assistant-v2"
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${PRODUCT_EXECUTABLE}.lnk"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,263 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Local-Cursor"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Local-Cursor"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Local-Cursor"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "1.1.8"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Local-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 WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!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
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
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"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!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_SKIP_WEBVIEW2
!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:
!endif
!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

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.Local-Cursor" version="1.1.8" 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>

1805
cmd/ext-tool/extractor.go Normal file

File diff suppressed because it is too large Load Diff

119
cmd/ext-tool/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
func bailIf(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func findPrettier() (string, error) {
// 尝试常见的 prettier 命令名
names := []string{"prettier", "prettier.cmd", "npx"}
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
}
func main() {
// 命令行参数
inputPath := flag.String("input", "", "Path to JS file (e.g., extensionHostProcess.js)")
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
flag.Parse()
// 如果没有 -input 参数,尝试从位置参数获取
if *inputPath == "" && flag.NArg() > 0 {
*inputPath = flag.Arg(0)
}
if *inputPath == "" {
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-output <dir>] [-skip-format]")
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
fmt.Fprintln(os.Stderr, "\nExample:")
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
os.Exit(1)
}
// 验证输入文件
info, err := os.Stat(*inputPath)
bailIf(err)
if info.IsDir() {
bailIf(fmt.Errorf("expected %s to be file, is dir", *inputPath))
}
// 设置输出目录
if *outputDir == "" {
wd, err := os.Getwd()
bailIf(err)
*outputDir = filepath.Join(wd, "cursor_proto")
}
// 复制到临时文件(不修改原文件)
fmt.Println("Copying source file to temp directory...")
originalFile, err := os.Open(*inputPath)
bailIf(err)
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
bailIf(err)
tempFileName := tempFile.Name()
_, err = io.Copy(tempFile, originalFile)
bailIf(err)
bailIf(originalFile.Close())
bailIf(tempFile.Close())
fmt.Printf("Temp file: %s\n", tempFileName)
// 格式化临时文件
if !*skipFormat {
prettierBin, err := findPrettier()
if err != nil {
fmt.Printf("Warning: %v\n", err)
fmt.Println("Skipping formatting, extraction may be less accurate...")
} else {
fmt.Println("Formatting file (this may take a while)...")
var prettierCmd *exec.Cmd
if filepath.Base(prettierBin) == "npx" {
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
} else {
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
}
out, err := prettierCmd.CombinedOutput()
if err != nil {
fmt.Printf("Prettier output: %s\n", string(out))
fmt.Println("Warning: formatting failed, continuing anyway...")
} else {
fmt.Println("Formatting complete")
}
}
} else {
fmt.Println("Skipping formatting (--skip-format)")
}
// 运行提取器
fmt.Println("Extracting Proto definitions...")
SetStrictMode(*strict)
ExtractProtos(tempFileName, *outputDir)
// 清理临时文件
os.Remove(tempFileName)
fmt.Printf("\nOutput directory: %s\n", *outputDir)
}

View File

@@ -0,0 +1,2 @@
cursor-tab-server-linux-amd64.tar
*.tar

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 18041
ENTRYPOINT ["/app/cursor-tab-server"]

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';"
```

View File

@@ -0,0 +1 @@
token: "eyJ..."

5
cursor-tab-server/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module cursor-tab-server
go 1.25
require gopkg.in/yaml.v3 v3.0.1

4
cursor-tab-server/go.sum Normal file
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
cursor-tab-server/main.go Normal file
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 = ":18041"
)
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
}

44
docs/architecture.md Normal file
View File

@@ -0,0 +1,44 @@
# 项目结构说明
## 目录布局
```
Local-Curosr/
├── cmd/ # 独立可执行工具入口
│ └── ext-tool/ # proto 提取工具,从 Cursor 扩展源码生成 .proto 定义
├── internal/ # 内部业务包(不对外暴露)
│ ├── app/ # Wails 应用启动与生命周期
│ ├── appdata/ # 用户数据目录管理
│ ├── backend/ # 后端服务核心
│ │ ├── agent/ # Agent 引擎(模型适配、prompt 引擎、协议)
│ │ ├── forwarder/ # 请求转发与 AI 处理
│ │ ├── server/ # HTTP 路由、中间件、配置
│ │ └── host.go # 后端宿主,管理 HTTP/HTTPS 监听器
│ ├── bridge/ # Wails 前端桥接层
│ ├── buildinfo/ # 构建版本信息
│ ├── certs/ # CA 证书管理
│ ├── client/ # 客户端服务(MITM 代理、CLI wrapper 生成)
│ ├── cursor/ # Cursor 宿主交互(代理设置、证书安装)
│ ├── historymetrics/ # 历史用量指标
│ ├── logger/ # 日志
│ ├── mitm/ # MITM 代理服务
│ ├── modelchannel/ # 模型通道解析
│ ├── netproxy/ # 出站网络代理
│ ├── prompt/ # 静态 prompt 资产与加载
│ └── runtime/ # 运行时注入(auth token、账号信息)
├── proto/ # Protobuf 定义与扩展提取工具
├── scripts/ # 构建/发布辅助脚本
├── build/ # 平台构建配置与资源
├── cursor-tab-server/ # 独立子模块:Tab 补全服务
├── main.go # 主入口
├── go.mod # Go 模块定义
├── Taskfile.yml # 构建/发布任务
└── build/config.yml # Wails 构建配置
```
## 数据流
```
Cursor IDE → MITM(38081) → backend(39091) → 路由 → forwarder → agent/model → 上游模型
CLI agent → tls-bypass → HTTPS(39092) → 同一 backend mux → 同上
```

41
docs/build-environment.md Normal file
View File

@@ -0,0 +1,41 @@
# Windows 构建环境
以下路径于 2026-11-03 在当前机器核实。项目发布不依赖额外安装 NSIS;使用下方已缓存的可执行文件。
| 工具 | 实际路径 |
|---|---|
| Git | `D:\WorkProgarm\DevTools\Git\cmd\git.exe` |
| Go | `D:\WorkProgarm\DevTools\go\bin\go.exe` |
| Wails v3 | `D:\WorkProgarm\DevCache\go-path\bin\wails3.exe` |
| Task | `C:\Users\FuDie915\AppData\Local\Microsoft\WinGet\Links\task.exe` |
| Node.js | `C:\Users\FuDie915\AppData\Local\fnm_multishells\14812_1793715538718\node.exe` |
| Yarn | `C:\Users\FuDie915\AppData\Local\fnm_multishells\14812_1793715538718\yarn.ps1` |
| protoc | `C:\Users\FuDie915\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe` |
| protoc-gen-go | `D:\WorkProgarm\DevCache\go-path\bin\protoc-gen-go.exe` |
| protoc-gen-connect-go | `D:\WorkProgarm\DevCache\go-path\bin\protoc-gen-connect-go.exe` |
| NSIS | `C:\Users\FuDie915\AppData\Local\electron-builder\Cache\nsis\nsis-3.0.4.1\Bin\makensis.exe` |
| PowerShell / Compress-Archive | `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` |
Node.js 和 Yarn 路径由 fnm 按会话生成,重新打开终端后应以 `Get-Command node, yarn` 的实际输出为准。NSIS 已验证可执行,版本为 v3.04。
## 构建前设置
NSIS 当前未加入全局 `PATH`,在当前 PowerShell 会话中加入后即可使用标准发布任务:
```powershell
$env:Path = "C:\Users\FuDie915\AppData\Local\electron-builder\Cache\nsis\nsis-3.0.4.1\Bin;$env:Path"
task release:prepare
```
## 标准 Windows 输出
以 v1.1.8 为例,产物写入 `bin/release/v1.1.8/`:
```text
Local-Cursor.exe
Local-Cursor-amd64-installer.exe
local-cursor-v1.1.8-installer.zip
local-cursor-v1.1.8-windows-x64-portable.zip
```
其中 portable ZIP 内的程序名为 `windows-64.exe`,installer ZIP 内的安装包名为 `Local-Cursor-amd64-installer.exe`。

2
frontend/.yarnrc Normal file
View File

@@ -0,0 +1,2 @@
registry "https://registry.npmmirror.com"
network-timeout 120000

12
frontend/index.html Normal file
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>Local-Cursor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

10
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@bindings/*": ["bindings/*"]
}
},
"include": ["src/**/*"]
}

38
frontend/package.json Normal file
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 --minify false --mode development",
"build": "node ./scripts/run-vite-build.mjs --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"
]
}

View File

@@ -0,0 +1,704 @@
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"];
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 transformVueTemplate(templateCode, filePath, rootDir) {
const ast = parseTemplate(templateCode, { comments: true });
const replacements = [];
const records = [];
walkTemplateNode(ast, (node, parent) => {
if (node.type === 2 && containsHan(node.content)) {
const record = buildMessageRecord(
filePath,
rootDir,
node.content.trim(),
0,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
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,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
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,
{
line: node.loc.start.line,
column: node.loc.start.column + 1,
},
);
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);
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;
}
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,
};
},
};
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

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
frontend/public/style.css Normal file
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;
}

BIN
frontend/public/wails.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

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);
});

34
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<MainLayout />
<MessageProvider />
<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)"
/>
<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 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";
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,417 @@
<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, saveHomeMetricsRefreshInterval, saveIncludeCacheWriteInHitRate, saveHomeMetricsPersistent } from "@/state/appState";
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
import { computed, ref, watch } from "vue";
const emit = defineEmits(["refresh"]);
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: "",
},
});
const homeMetricsConfigSaving = ref(false);
const homeMetricsConfigError = ref("");
const homeMetricsModeSaving = ref(false);
const refreshIntervalInput = ref(String(appState.homeMetricsRefreshInterval ?? 60));
const refreshIntervalSaving = ref(false);
watch(
() => appState.homeMetricsRefreshInterval,
(value) => {
refreshIntervalInput.value = String(value ?? 60);
},
);
async function commitRefreshInterval() {
const parsed = Number(refreshIntervalInput.value);
const nextValue = Number.isFinite(parsed) ? Math.round(parsed) : NaN;
if (!Number.isFinite(nextValue) || nextValue === appState.homeMetricsRefreshInterval) {
refreshIntervalInput.value = String(appState.homeMetricsRefreshInterval ?? 60);
return;
}
refreshIntervalSaving.value = true;
try {
const result = await saveHomeMetricsRefreshInterval(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
refreshIntervalSaving.value = false;
refreshIntervalInput.value = String(appState.homeMetricsRefreshInterval ?? 60);
}
}
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);
});
// 命中率口径统一由后端按 includeCacheWriteInHitRate 配置计算,前端只消费结果。
const selectedCacheHitRate = computed(() => {
const rate = Number(props.metrics?.cacheHitRate);
return Number.isFinite(rate) ? rate : null;
});
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
const homeMetricsPersistent = computed(() => appState.homeMetricsPersistent);
const homeMetricsModeLabel = computed(() => homeMetricsPersistent.value ? "累计统计" : "本次运行");
async function toggleHomeMetricsPersistent(value) {
const nextValue = Boolean(value);
homeMetricsModeSaving.value = true;
try {
const result = await saveHomeMetricsPersistent(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsModeSaving.value = false;
}
}
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}`,
].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"),
);
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;
}
}
</script>
<template>
<div>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
<div class="flex items-center gap-3 text-xs text-[#666]">
<div class="flex items-center gap-1">
<span class="text-[#666]">{{ homeMetricsModeLabel }}</span>
<Tooltip>
<div class="w-[280px] space-y-2">
<Switch
compact
label="累计统计"
description="开启时展示全部历史数据,关闭时仅展示本次运行以来的增量"
enabled-text="当前展示累计数据"
disabled-text="当前展示本次运行增量"
:enabled="homeMetricsPersistent"
:busy="homeMetricsModeSaving"
:disabled="homeMetricsModeSaving"
@change="toggleHomeMetricsPersistent"
/>
<div class="text-[11px] text-[#666] leading-[16px]">
两种模式下后端始终写入持久化数据,切换不会清除历史记录。
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<span class="text-[11px] text-[#666] leading-[16px]">定时刷新</span>
<div class="flex items-center gap-1">
<input
v-model="refreshIntervalInput"
type="text"
inputmode="numeric"
class="h-6 w-[56px] rounded-[4px] border border-[#383838] bg-[#141414] px-2 text-[11px] text-white outline-none focus:border-white"
:disabled="refreshIntervalSaving"
@keydown.enter="commitRefreshInterval"
@blur="commitRefreshInterval"
/>
<span class="text-[11px] text-[#666]">秒</span>
</div>
</div>
</div>
</Tooltip>
<span class="icon-[mdi--chart-timeline-variant-shimmer] text-[14px] text-[#666]"></span>
</div>
<span>刷新统计</span>
<button
type="button"
class="flex items-center justify-center h-[24px] w-[24px] rounded-[4px] border border-[#383838] bg-[#141414] text-[#a8a8a8] transition-colors duration-150 hover:border-[#666] 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="grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#2a2a2a] bg-[#0a0a0a] min-h-[130px]"
>
<div class="min-w-0 px-4 py-4 flex flex-col gap-3">
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>缓存命中率</span>
<Tooltip>
<div class="w-[280px] space-y-3">
<div class="border-b border-[#2a2a2a] 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-[#ff4444]">
{{ homeMetricsConfigError }}
</div>
</div>
</Tooltip>
</div>
<CacheHitRateChart :rate="selectedCacheHitRate" />
</div>
<div
class="min-w-0 border-l border-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<span>对话轮次</span>
<Tooltip :content="turnsTooltipContent" />
</div>
<div>
<div
class="text-[30px] leading-none text-white"
:title="formatInteger(metrics.turnsTotal)"
>
{{ formatCompactInteger(metrics.turnsTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#666]">
有效
<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-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<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-[#666]">
Prompt
<span :title="formatInteger(metrics.promptTokensTotal)">
{{ formatCompactInteger(metrics.promptTokensTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#2a2a2a] px-4 py-4 flex flex-col justify-between"
>
<div class="flex items-center justify-start gap-1 text-xs text-[#666]">
<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-[#666]">
缓存读写
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,32 @@
<script setup>
import Select from "@/components/ui/Select.vue";
import { useLocale } from "@/i18n/runtime";
const props = defineProps({
border: { type: Boolean, default: true },
compact: { type: Boolean, default: false },
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"
:compact="compact"
:aria-label="ariaLabel"
:button-class="buttonClass"
:menu-class="menuClass"
:placeholder="placeholder"
@update:model-value="setLocale"
/>
</div>
</template>

View File

@@ -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-[#333] bg-[#0f0f0f]";
}
if (normalizedStatus.value === "running") {
return "border-[#444] bg-[#141414]";
}
if (normalizedStatus.value === "error") {
return "border-[#555] bg-[#141414]";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "border-[#444] bg-[#1c1c1c]";
}
if (normalizedStatus.value === "success") {
return "border-[#555] bg-[#1c1c1c]";
}
return "border-[#343434] bg-[#1c1c1c]";
});
const summaryClass = computed(() => {
if (props.stale) {
return "text-[#666]";
}
if (normalizedStatus.value === "running") {
return "text-[#a8a8a8]";
}
if (normalizedStatus.value === "error") {
return "text-white";
}
if (normalizedStatus.value === "success" && props.result?.tokensEstimated) {
return "text-[#a8a8a8]";
}
if (normalizedStatus.value === "success") {
return "text-white";
}
return "text-[#a8a8a8]";
});
</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-[#666]">
<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-[#444] px-2 py-1 text-xs text-[#a8a8a8]"
>
需重测
</span>
</div>
<div v-if="stale" class="mt-2 text-xs text-[#666]">
配置已变更,请重新测试
</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-white">{{ 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-white">{{ result?.outputTokens ?? 0 }}</div>
</div>
</div>
<div
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
class="mt-2 text-xs text-[#666]"
>
输出 Token 为估算值
</div>
</div>
</template>

View File

@@ -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: ["#ffffff", "#2a2a2a"],
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>

View File

@@ -0,0 +1,30 @@
<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-[#a8a8a8] transition-colors duration-150 hover:text-white active:text-white"
>
<slot />
</button>
<button
v-else
type="button"
class="!whitespace-nowrap cursor-pointer inline-flex items-center gap-[6px] min-h-[28px] rounded-[4px] px-[10px] py-[5px] text-[12px] font-medium transition-all duration-150 active:scale-[.98] border"
:class="{
'bg-[#141414] border-[#383838] text-white hover:bg-[#1c1c1c] hover:border-[#666]': variant === 'default',
'bg-white border-white text-black hover:bg-[#a8a8a8] hover:border-[#a8a8a8]': variant === 'primary',
}"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,6 @@
<script setup></script>
<template>
<div class="rounded-[8px] border border-[#2a2a2a] bg-[#0a0a0a] p-4">
<slot />
</div>
</template>

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-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none transition-colors focus:border-white 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-[#666] transition-colors hover:text-white focus:text-white 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>

View File

@@ -0,0 +1,97 @@
<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/60 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] border border-[#383838] bg-[#141414] p-5 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.8)]"
@click.stop
>
<h3 class="mb-3 text-base font-medium text-white">
{{ title }}
</h3>
<p class="mb-3 text-sm leading-relaxed text-[#a8a8a8]">
{{ content }}
</p>
<input
:value="modelValue"
:placeholder="placeholder"
type="text"
class="mb-5 h-9 w-full rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
@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>
</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>

View File

@@ -0,0 +1,83 @@
<script setup>
import { messageState, provideMessage } from "@/composables/useMessage";
provideMessage();
const MESSAGE_THEME = {
success: {
containerClass: "bg-white text-black",
iconClass: "icon-[dashicons--yes]",
iconExtraClass: "",
},
error: {
containerClass: "bg-[#1c1c1c] text-white border border-[#555]",
iconClass: "",
iconExtraClass: "",
},
info: {
containerClass: "bg-[#1c1c1c] text-white",
iconClass: "",
iconExtraClass: "",
},
loading: {
containerClass: "bg-[#1c1c1c] 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>

View File

@@ -0,0 +1,82 @@
<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/60 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] border border-[#383838] bg-[#141414] p-5 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.8)]"
@click.stop
>
<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-[#a8a8a8]">
{{ 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>
</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>

View File

@@ -0,0 +1,347 @@
<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 },
compact: { type: Boolean, default: false },
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-[4px] bg-[#141414] px-3 text-left text-sm text-white outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="[
border
? 'w-full justify-between gap-2 border border-[#383838] focus:border-white'
: compact
? 'w-full justify-center gap-1 border border-transparent focus-visible:ring-2 focus-visible:ring-white/30'
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-white/30',
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' : compact ? 'min-w-0 flex-1' : 'shrink-0',
selectedOption
? (border ? 'text-white' : 'text-current')
: 'text-[#666]',
]"
>
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
<span class="truncate">{{ selectedLabel }}</span>
</span>
<span
v-if="!compact"
class="pointer-events-none center-row transition-transform duration-200"
:class="[border ? 'text-[#666]' : '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-[#383838] bg-[#141414] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.8)]"
: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-white/10 text-white'
: 'text-white hover:bg-[#1c1c1c]',
activeIndex === index ? 'bg-[#1c1c1c]' : '',
]"
: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>

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-[#a8a8a8]"
>
{{ description }}
</div>
<div
:class="[
compact ? 'text-[11px] leading-[16px]' : 'text-xs',
enabled ? 'text-white' : 'text-[#666]',
]"
>
{{ busy ? busyText : enabled ? enabledText : disabledText }}
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="enabled"
:disabled="disabled || busy"
class="relative inline-flex h-[18px] w-[34px] shrink-0 cursor-pointer rounded-full outline-none transition-all duration-200 ease-out disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-white/30"
:class="enabled ? 'bg-white' : 'bg-[#1c1c1c] border border-[#383838]'"
@click="handleToggle"
>
<span
class="absolute top-[2px] left-[2px] inline-flex h-[12px] w-[12px] rounded-full transition-all duration-200 ease-out"
:class="enabled ? 'translate-x-[16px] bg-black' : 'translate-x-0 bg-[#666]'"
/>
</button>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom";
import copyTextToClipboard from "copy-text-to-clipboard";
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watchPostEffect } from "vue";
const props = defineProps({
content: { type: String, default: "" },
copyable: { type: Boolean, default: false },
copyText: { type: String, default: "" },
});
const slots = useSlots();
const HIDE_DELAY_MS = 300;
const COPY_RESET_DELAY_MS = 1500;
const triggerRef = ref(null);
const tooltipRef = ref(null);
const isOpen = ref(false);
const tooltipStyle = ref({});
const copied = ref(false);
let hideTimer = null;
let copyResetTimer = null;
const copyValue = computed(() => String(props.copyText || props.content || "").trim());
const hasContent = computed(() => !!props.content || !!slots.default);
const showCopyButton = computed(() => props.copyable && !!copyValue.value);
function showTooltip() {
if (!hasContent.value) {
return;
}
clearHideTimer();
isOpen.value = true;
nextTick(() => {
updatePosition();
});
}
function hideTooltip() {
isOpen.value = false;
}
function clearHideTimer() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
function clearCopyResetTimer() {
if (copyResetTimer) {
window.clearTimeout(copyResetTimer);
copyResetTimer = null;
}
}
function scheduleHideTooltip() {
clearHideTimer();
hideTimer = window.setTimeout(() => {
hideTooltip();
hideTimer = null;
}, HIDE_DELAY_MS);
}
function updatePosition() {
if (!triggerRef.value || !tooltipRef.value) {
return;
}
computePosition(triggerRef.value, tooltipRef.value, {
placement: "top",
middleware: [
offset(10),
flip({ padding: 12 }),
shift({ padding: 12 }),
],
}).then(({ x, y }) => {
tooltipStyle.value = {
left: `${x}px`,
top: `${y}px`,
};
});
}
function handleCopy() {
if (!copyValue.value) {
return;
}
copyTextToClipboard(copyValue.value);
copied.value = true;
clearCopyResetTimer();
copyResetTimer = window.setTimeout(() => {
copied.value = false;
copyResetTimer = null;
}, COPY_RESET_DELAY_MS);
}
watchPostEffect((cleanup) => {
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
return;
}
const stop = autoUpdate(triggerRef.value, tooltipRef.value, updatePosition);
cleanup(() => {
stop();
});
});
onBeforeUnmount(() => {
clearHideTimer();
clearCopyResetTimer();
hideTooltip();
});
</script>
<template>
<span class="inline-flex">
<button
ref="triggerRef"
type="button"
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#666] transition-colors duration-150 hover:text-white"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
@focus="showTooltip"
@blur="scheduleHideTooltip"
>
<span class="icon-[mdi--information-outline] text-[14px]"></span>
</button>
<Teleport to="body">
<div
v-if="isOpen"
ref="tooltipRef"
class="fixed z-[10000] flex max-h-[320px] max-w-[420px] flex-col overflow-hidden rounded-[8px] border border-[#383838] bg-[#141414] px-3 py-2 text-left text-[12px] leading-relaxed text-white shadow-[0_12px_32px_rgba(0,0,0,0.6)]"
:style="tooltipStyle"
@mouseenter="showTooltip"
@mouseleave="scheduleHideTooltip"
>
<div v-if="showCopyButton" class="mb-2 flex shrink-0 justify-end">
<button
type="button"
class="center-row gap-1 rounded-[6px] border border-[#383838] bg-[#1c1c1c] px-2 py-1 text-[11px] text-white transition-colors duration-150 hover:border-[#666] hover:bg-[#2a2a2a]"
@click="handleCopy"
>
<span :class="copied ? 'icon-[mdi--check]' : 'icon-[mdi--content-copy]'" class="text-[13px]"></span>
<span>{{ copied ? "已复制" : "拷贝" }}</span>
</button>
</div>
<div class="min-h-0 overflow-auto break-words">
<slot>
<div class="whitespace-pre-wrap">{{ content }}</div>
</slot>
</div>
</div>
</Teleport>
</span>
</template>

View File

@@ -0,0 +1,36 @@
import { reactive } from "vue";
export const inputModalState = reactive({
visible: false,
title: "提示",
content: "",
placeholder: "",
value: "",
_resolve: null,
});
/**
* 显示输入弹窗,返回 Promise<string|null>
* @param {Object} options - { title, content, placeholder, defaultValue }
* @returns {Promise<string|null>} - string=确定后的输入值, null=取消
*/
export function showInputModal(options = {}) {
return new Promise((resolve) => {
inputModalState.visible = true;
inputModalState.title = options.title ?? "提示";
inputModalState.content = options.content ?? "";
inputModalState.placeholder = options.placeholder ?? "";
inputModalState.value = String(options.defaultValue ?? "");
inputModalState._resolve = resolve;
});
}
export function resolveInputModal(ok) {
const value = String(inputModalState.value ?? "").trim();
inputModalState.visible = false;
inputModalState._resolve?.(ok ? value : null);
inputModalState._resolve = null;
if (!ok) {
inputModalState.value = "";
}
}

View File

@@ -0,0 +1,109 @@
import { inject, provide, reactive } from "vue";
const MESSAGE_API_SYMBOL = Symbol("message-api");
const MIN_VISIBLE_MS = 300;
let messageSeed = 0;
const messageState = reactive({
current: null,
});
function clearMessageTimer(item) {
if (item?.timer) {
clearTimeout(item.timer);
item.timer = null;
}
}
function removeMessage(id, options = {}) {
if (!messageState.current || messageState.current.id !== id) {
return;
}
const current = messageState.current;
const elapsed = Date.now() - current.shownAt;
const force = options.force === true;
if (!force && elapsed < MIN_VISIBLE_MS) {
clearMessageTimer(current);
current.timer = window.setTimeout(() => {
removeMessage(id, { force: true });
}, MIN_VISIBLE_MS - elapsed);
return;
}
clearMessageTimer(current);
messageState.current = null;
}
function showMessage(options = {}) {
const type = typeof options.type === "string" ? options.type : "info";
const content = String(options.content || "").trim();
if (!content) {
return null;
}
if (messageState.current) {
clearMessageTimer(messageState.current);
}
const duration = Number.isFinite(options.duration)
? Math.max(0, options.duration)
: type === "loading"
? 0
: 2400;
const id = `message-${Date.now()}-${messageSeed += 1}`;
const item = {
id,
type,
content,
shownAt: Date.now(),
timer: null,
};
if (duration > 0) {
item.timer = window.setTimeout(() => {
removeMessage(id);
}, Math.max(duration, MIN_VISIBLE_MS));
}
messageState.current = item;
return id;
}
export function createMessageApi() {
return {
state: messageState,
show: showMessage,
success(content, options = {}) {
return showMessage({ ...options, type: "success", content });
},
error(content, options = {}) {
return showMessage({ ...options, type: "error", content });
},
info(content, options = {}) {
return showMessage({ ...options, type: "info", content });
},
loading(content, options = {}) {
return showMessage({ ...options, type: "loading", content });
},
remove: removeMessage,
clear() {
if (messageState.current) {
removeMessage(messageState.current.id, { force: true });
}
},
};
}
const defaultMessageApi = createMessageApi();
export function provideMessage() {
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
return defaultMessageApi;
}
export function useMessage() {
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
}
export { messageState, showMessage, removeMessage };

View File

@@ -0,0 +1,37 @@
import { reactive } from "vue";
export const modalState = reactive({
visible: false,
title: "提示",
content: "",
confirmText: "确定",
cancelText: "取消",
showCancel: true,
confirmDisabled: false,
_resolve: null,
});
/**
* 显示确认弹窗,返回 Promise<boolean>
* @param {Object} options - { title, content }
* @returns {Promise<boolean>} - true=确定, false=取消
*/
export function showModal(options = {}) {
return new Promise((resolve) => {
modalState.visible = true;
modalState.title = options.title ?? "提示";
modalState.content = options.content ?? "";
modalState.confirmText = options.confirmText ?? "确定";
modalState.cancelText = options.cancelText ?? "取消";
modalState.showCancel = options.showCancel ?? true;
modalState.confirmDisabled = options.confirmDisabled ?? false;
modalState._resolve = resolve;
});
}
export function resolveModal(ok) {
modalState.visible = false;
const resolve = modalState._resolve;
modalState._resolve = null;
resolve?.(ok);
}

View File

@@ -0,0 +1,10 @@
export const LOCALE_STORAGE_KEY = "cursor-client:locale:v1";
export const LOCALE_STORAGE_SOURCE_KEY = "cursor-client:locale-source:v1";
export const SOURCE_LOCALE = "zh-CN";
export const DEFAULT_LOCALE = "en-US";
export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
export const LOCALE_OPTIONS = [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
{ label: "日本語", value: "ja-JP" },
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "No release notes",
"02bc2e95bf49e587": "No",
"03b11112dc970014": "Base URL",
"045261cc748d4300": "Token budget allowed during Anthropic's thinking phase. Leave blank to use the default.",
"047ec6b71d0cec08": "Control whether requests on the whitelist main path go through the local service or return to the original Cursor upstream endpoint",
"04f632dd4f034d5e": "{0} context window must be a positive integer",
"051836569928a9f9": "Edit",
"054d763265603305": "e.g. 278000 (leave blank to use the default)",
"05c8a9238c702efa": "Direct Cursor Mode",
"0647728439b5da2e": "You can configure the routing mode and model channels. Runtime logs are stored in",
"092b520558eff5f2": "Not tested",
"0b0e7478e41fe677": "{0} tooltip text cannot be empty",
"0c3b4cf7aa259edb": "Operation failed",
"0dde813d719dbd01": "Failed to open homepage",
"0e40a09ab4e664ab": "Duplicate model channel detected. Check the combination of url, modelID, apiKey, and displayName",
"1117a2f86030d03b": "Cache reads and writes are included in Prompt-side statistics.",
"11afd2a534395b18": "Valid",
"124be3f86f197802": "Token Usage",
"13b61c5f697b6700": "Cache Hit Rate",
"15d124b200ddabed": "Maximum number of context tokens the model can accept in a single request. Leave blank to use the default.",
"18b7312022cd1840": "Start Service",
"1af38868896cf53d": "Routing mode only supports local or upstream",
"1b7d24b212e52c54": "{0} reasoning effort only supports low, medium, high, and xhigh",
"1bc77f5ab979f4c1": "Add Model Settings",
"1e238093b79b3165": "Uses 278000 by default when left blank",
"24343a2096988d42": "Failed to open",
"253d4a3428c648fb": "Cache write tokens: {0}",
"258e4620e2108793": "Uses 4096 by default when left blank",
"26a3855aed1d8d17": "Service not running",
"281eb6d08c9960d0": "{0} thinking budget token must be a positive integer",
"28aeffc70ceb4267": "Change the display language for this interface. The setting takes effect immediately and is saved on this device.",
"2caeaec539e78898": "Thinking Budget Token",
"2cd0f3be8738a86c": "Cancel",
"2d706f7981b45a7b": "Local settings saved",
"2f9daa828907b93f": "Delete",
"30bb57a50caedc38": "e.g. For everyday code completion and Q&A",
"32b3c9a50003f77a": "Output tokens are estimated",
"33d2d273e2bd5f88": "Failed to open user guide",
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
"35076178fe79a210": "Configuration changed. Please test again.",
"36c149a9b3e8dca0": "models configured yet.",
"37d23612f78a2e63": "Restart Now to Update",
"392d0dceb45998d3": "Extreme",
"393df9bb13ea4900": "Hit",
"3af7e5489e61ea51": "Refreshing",
"3bf8512aa520ed21": "Local Service Mode",
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
"3d13868593ae4eeb": "Interface Language",
"3ea83f9f55062582": "Release date: {0}",
"3edda85621fd03b2": "model adapters",
"42aa8e01e98c0d8c": "Total Duration",
"468adaa418ee1475": "e.g. https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
"497c85690c4cc0fc": "No data",
"4b8d11bf235e9213": "Current hit rate: {0}",
"4d8c1c5b42830791": "Unknown",
"51194c3ad014fb29": "Retest required",
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
"56627c94a9decee6": "Max Output Tokens",
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
"5d1687a4a41883fd": "Stopping...",
"6106f0a12583a334": "Refresh failed",
"62873083fcaed27d": "Session Statistics",
"6309a3bb5ba4c714": "Save failed",
"636e3deffc1e960a": "Model {0}",
"63d90d977348ab1f": "Duplicate",
"64d2730f2ae37997": "Raw Response",
"65cc5fd2e6ce6e75": "Backend started, proxy not started",
"66af574b8948fe83": "{0} API key cannot be empty",
"675109292da4eb36": "Not tested yet",
"699fe7ade5407687": "Direct Mode",
"6a7b96f399e58138": "e.g. sk-xxxxxx",
"6aa8f49cc992dfd7": "Test",
"6ae23d6d7cb18592": "Service error",
"6e584e3d5ce64aa0": "Save Settings",
"737225e2904673fc": "Estimated output tokens: {0}",
"7520bd50a5ee5471": "Stop testing {0}/{1}",
"753d8bb0da9913ce": "Duplication failed",
"77c9e582e85583af": "Test failed",
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
"7b6187c41e88b70c": "Testing...",
"7bf8e2c07e084d09": "Model Editor",
"7e9e334aeb0bdc07": "Service operation failed",
"7f68ebad19ba6bcd": "Check for Updates",
"81123c56d5d880d0": "API Key",
"86df7ec743047234": "Service running",
"87ed126f7bd1121e": "Routing Mode",
"899add6275682210": "Uses 500000 by default when left blank",
"8c0d84831a3c3d5b": "Currently in Local Service Mode",
"8c1935935600e336": "Model Test",
"8cbcf741e727dbf7": "Model Settings",
"8d1de152be6360ce": "Valid ratio: {0}",
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
"8f6f8d979c981ced": "Copied",
"8faa670b512b6b9b": "Open Model Settings",
"917b1c1f18d0276b": "Saving...",
"9196835e388d2550": "Test All",
"91cba5c107a51892": "/ Invalid",
"92059fe6cd713db4": "The model name actually sent to the server, for example gpt-4.1 or claude-sonnet.",
"93e08803675e378b": "Model ID",
"942ff2d88baca0c6": "Checking for updates...",
"986678eccf56dc28": "Service status is being updated. Please try again later.",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "The base URL of the model service, usually an OpenAI- or Anthropic-compatible endpoint.",
"9b17fa889b307f7f": "Valid turns: {0}",
"9c38b6e9bf94abec": "Switched to Direct Cursor Mode",
"9cd4ac17428b86e4": "Cache hit rate = cache read tokens / prompt tokens",
"9d2ca261281a158a": "Later",
"9dc0825fba5422e4": "Loading...",
"a026f37e613cf48b": "Output Tokens",
"a1a038dfa16c3ede": "You're already on the latest version (v{0}).",
"a3030bf8f16dc63c": "Save",
"a325d25c69e7256d": "Model settings not found; cannot duplicate",
"a4dd8bb7e8b6eb31": "Show API Key",
"a55a88237df85d98": "Currently in Direct Mode",
"a567bdaa11367f26": "Medium",
"a5f1bd344c92e195": "The API key required to call this model service.",
"a693d69af48bfe48": "Save and Test",
"a98585871c5313ff": "Display Name",
"aa9e366f68d3d097": "Low",
"ac217e4d1ca410f1": "New version available",
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
"ae5a738238463a92": "Hide API Key",
"aed55419ce62f08e": "Switching...",
"b1c27820fec23edb": "High",
"b42049dcf8a05ef7": "Switched to Local Service Mode",
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
"b765005f69fa971f": "e.g. gpt-4.1",
"b90a8ac9c488ce46": "Select language",
"ba40014ff496f64e": "Type",
"bb074b86a98f6911": "Context Window",
"bbacfde55a92869f": "A higher hit rate means more repeated context is being reused.",
"bc87a4121a0873b3": "Refresh Stats",
"bd4464ea88d3f24a": "Total turns: {0}",
"bef280f9eb392495": "Conversation Turns",
"c228558cf257fc49": "Delete failed",
"c3e9c3c60020b8b7": "Select Mode",
"c69f5bce63b9f14c": "Settings Folder",
"c6f743953145f40b": "e.g. 4096 (leave blank to use the default)",
"c8c14507b2d37395": "Reasoning Effort",
"c98e118e0a43f078": "Model",
"ca00a39fcea70dc6": "Starting...",
"ca1d1059408b3837": "Invalid turns: {0}",
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
"d0325067fed88e5a": "Cache hit rate {0}",
"d08fd4224abcd69d": "Switch failed",
"d1bde4a4e057b2c7": "[MainLayout] Failed to load author info",
"d20ab96566d33f25": "{0} display name cannot be empty",
"d2243e1d44b2a94e": "Edit Model Settings",
"d3209b935ae86797": "Model settings not found; cannot delete",
"d373809ab86ba93b": "Copy",
"d3b1da3088ddd334": "Model test failed",
"d7da2aabd35772ec": "e.g. 500000 (leave blank to use the default)",
"da590a8fe3ce4de0": "Please select",
"daede9881787abe7": "Notes",
"dbee6e7139243362": "{0} base URL cannot be empty",
"dc82c5e8fb2ab777": "Version: v{0}",
"de8184da1ef88d03": "Configured",
"e14c41ef2b7253c9": "Total request tokens: {0}",
"e406825e0a72d2c2": "Local Settings",
"e552c2accdbf5178": "Add Model",
"e6faccfddce722e8": "Cache read tokens: {0}",
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | First token {1}",
"ec99e5c45d648fd6": "Update failed",
"f1e0fc261d42fe29": "Notes shown when hovering over the model list.",
"f363622480699c52": "Reasoning effort only applies to some models that support reasoning_effort. Not all models do. Higher values are usually more stable, but may also be slower.",
"f3a76d896853c1df": "Miss",
"f474a4108aba4c4c": "Stop Service",
"f56c6c82203b33f6": "Notice",
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
"fac2a67ad87807c4": "OK",
"fb7a4c81729ed0ca": "Stopping...",
"fec45092945f8790": "User Guide"
}

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "更新内容はありません",
"02bc2e95bf49e587": "まだ",
"03b11112dc970014": "ベース URL",
"045261cc748d4300": "Anthropic の思考フェーズで使用できる予算 Token 数。空欄の場合はデフォルト値を使用します。",
"047ec6b71d0cec08": "ホワイトリストのメイン経路のリクエストをローカルサービス経由にするか、元の Cursor 上流アドレスに戻すかを制御します",
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
"051836569928a9f9": "編集",
"054d763265603305": "例: 278000(空欄でデフォルト値)",
"05c8a9238c702efa": "Cursor 直結モード",
"0647728439b5da2e": "ルーティングモードとモデルチャネルを設定できます。実行ログは次にあります",
"092b520558eff5f2": "未テスト",
"0b0e7478e41fe677": "{0} のツールチップは必須です",
"0c3b4cf7aa259edb": "操作に失敗しました",
"0dde813d719dbd01": "ホームページを開けませんでした",
"0e40a09ab4e664ab": "モデルチャネルが重複しています。url、modelID、apiKey、displayName の組み合わせを確認してください",
"1117a2f86030d03b": "キャッシュの読み書きは Prompt 側の統計に含まれます。",
"11afd2a534395b18": "有効",
"124be3f86f197802": "Token 使用量",
"13b61c5f697b6700": "キャッシュヒット率",
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
"18b7312022cd1840": "サービスを開始",
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
"1b7d24b212e52c54": "{0} の推論強度は low、medium、high、xhigh のみサポートします",
"1bc77f5ab979f4c1": "モデル設定を追加",
"1e238093b79b3165": "空欄で 278000",
"24343a2096988d42": "開けませんでした",
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
"258e4620e2108793": "空欄で 4096",
"26a3855aed1d8d17": "サービスは起動していません",
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
"2caeaec539e78898": "思考予算 Token",
"2cd0f3be8738a86c": "キャンセル",
"2d706f7981b45a7b": "ローカル設定を保存しました",
"2f9daa828907b93f": "削除",
"30bb57a50caedc38": "例: 日常的なコード補完や Q&A 用",
"32b3c9a50003f77a": "出力 Token は推定値です",
"33d2d273e2bd5f88": "ユーザーガイドを開けませんでした",
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
"35076178fe79a210": "設定が変更されました。再テストしてください",
"36c149a9b3e8dca0": "モデルは設定されていません。",
"37d23612f78a2e63": "今すぐ再起動して更新",
"392d0dceb45998d3": "最高",
"393df9bb13ea4900": "ヒット",
"3af7e5489e61ea51": "更新中",
"3bf8512aa520ed21": "ローカルサービスモード",
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
"3d13868593ae4eeb": "表示言語",
"3ea83f9f55062582": "公開日時: {0}",
"3edda85621fd03b2": "件のモデルアダプター",
"42aa8e01e98c0d8c": "総所要時間",
"468adaa418ee1475": "例: https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
"497c85690c4cc0fc": "データなし",
"4b8d11bf235e9213": "現在のヒット率: {0}",
"4d8c1c5b42830791": "不明",
"51194c3ad014fb29": "再テストが必要",
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"56627c94a9decee6": "最大出力 Token",
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
"5d1687a4a41883fd": "停止中...",
"6106f0a12583a334": "再読み込みに失敗しました",
"62873083fcaed27d": "セッション統計",
"6309a3bb5ba4c714": "保存に失敗しました",
"636e3deffc1e960a": "モデル {0}",
"63d90d977348ab1f": "複製",
"64d2730f2ae37997": "生のレスポンス",
"65cc5fd2e6ce6e75": "バックエンドは起動済み、プロキシは未起動です",
"66af574b8948fe83": "{0} の API キーは必須です",
"675109292da4eb36": "まだテストしていません",
"699fe7ade5407687": "直結モード",
"6a7b96f399e58138": "例: sk-xxxxxx",
"6aa8f49cc992dfd7": "テスト",
"6ae23d6d7cb18592": "サービスエラー",
"6e584e3d5ce64aa0": "設定を保存",
"737225e2904673fc": "推定出力 Token: {0}",
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
"753d8bb0da9913ce": "複製に失敗しました",
"77c9e582e85583af": "テスト失敗",
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
"7b6187c41e88b70c": "テスト中...",
"7bf8e2c07e084d09": "モデル編集",
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
"7f68ebad19ba6bcd": "アップデートを確認",
"81123c56d5d880d0": "API キー",
"86df7ec743047234": "サービス稼働中",
"87ed126f7bd1121e": "ルーティングモード",
"899add6275682210": "空欄で 500000",
"8c0d84831a3c3d5b": "現在はローカルサービスモードです",
"8c1935935600e336": "モデルテスト",
"8cbcf741e727dbf7": "モデル設定",
"8d1de152be6360ce": "有効率: {0}",
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
"8f6f8d979c981ced": "コピーしました",
"8faa670b512b6b9b": "モデル設定を開く",
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "すべてテスト",
"91cba5c107a51892": "/ 異常",
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
"93e08803675e378b": "モデル ID",
"942ff2d88baca0c6": "アップデートを確認中...",
"986678eccf56dc28": "サービス状態を更新中です。しばらくしてからもう一度お試しください",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "モデルサービスの API ルート URL。通常は OpenAI または Anthropic 互換のエンドポイントです。",
"9b17fa889b307f7f": "有効ターン: {0}",
"9c38b6e9bf94abec": "Cursor 直結モードに切り替えました",
"9cd4ac17428b86e4": "キャッシュヒット率 = キャッシュ読込 Token / Prompt Token",
"9d2ca261281a158a": "後で",
"9dc0825fba5422e4": "読み込み中...",
"a026f37e613cf48b": "出力 Token",
"a1a038dfa16c3ede": "すでに最新バージョンです(v{0})。",
"a3030bf8f16dc63c": "保存",
"a325d25c69e7256d": "モデル設定が存在しないため複製できません",
"a4dd8bb7e8b6eb31": "API キーを表示",
"a55a88237df85d98": "現在は直結モードです",
"a567bdaa11367f26": "中",
"a5f1bd344c92e195": "このモデルサービスを呼び出すために必要な API キーです。",
"a693d69af48bfe48": "保存してテスト",
"a98585871c5313ff": "表示名",
"aa9e366f68d3d097": "低",
"ac217e4d1ca410f1": "新しいバージョンがあります",
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
"ae5a738238463a92": "API キーを隠す",
"aed55419ce62f08e": "切替中...",
"b1c27820fec23edb": "高",
"b42049dcf8a05ef7": "ローカルサービスモードに切り替えました",
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
"b765005f69fa971f": "例: gpt-4.1",
"b90a8ac9c488ce46": "言語を選択",
"ba40014ff496f64e": "タイプ",
"bb074b86a98f6911": "コンテキストウィンドウ",
"bbacfde55a92869f": "ヒット率が高いほど、重複するコンテキストがより多く再利用されていることを示します。",
"bc87a4121a0873b3": "統計を更新",
"bd4464ea88d3f24a": "総ターン: {0}",
"bef280f9eb392495": "会話ターン",
"c228558cf257fc49": "削除に失敗しました",
"c3e9c3c60020b8b7": "モードを選択",
"c69f5bce63b9f14c": "設定フォルダー",
"c6f743953145f40b": "例: 4096(空欄でデフォルト値)",
"c8c14507b2d37395": "推論強度",
"c98e118e0a43f078": "モデル",
"ca00a39fcea70dc6": "起動中...",
"ca1d1059408b3837": "異常ターン: {0}",
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
"d0325067fed88e5a": "キャッシュヒット率 {0}",
"d08fd4224abcd69d": "切替に失敗しました",
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
"d20ab96566d33f25": "{0} の表示名は必須です",
"d2243e1d44b2a94e": "モデル設定を編集",
"d3209b935ae86797": "モデル設定が存在しないため削除できません",
"d373809ab86ba93b": "コピー",
"d3b1da3088ddd334": "モデルテストに失敗しました",
"d7da2aabd35772ec": "例: 500000(空欄でデフォルト値)",
"da590a8fe3ce4de0": "選択してください",
"daede9881787abe7": "メモ",
"dbee6e7139243362": "{0} のベース URL は必須です",
"dc82c5e8fb2ab777": "バージョン: v{0}",
"de8184da1ef88d03": "設定済み",
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
"e406825e0a72d2c2": "ローカル設定",
"e552c2accdbf5178": "モデルを追加",
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
"ec99e5c45d648fd6": "アップデートに失敗しました",
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
"f3a76d896853c1df": "ミス",
"f474a4108aba4c4c": "サービスを停止",
"f56c6c82203b33f6": "お知らせ",
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
"fac2a67ad87807c4": "OK",
"fb7a4c81729ed0ca": "停止中...",
"fec45092945f8790": "ユーザーガイド"
}

View File

@@ -0,0 +1,176 @@
{
"02216368edc68816": "无更新说明",
"02bc2e95bf49e587": "当前还没有配置任何",
"03b11112dc970014": "接口地址",
"045261cc748d4300": "Anthropic 思考阶段允许消耗的预算 Token 数。留空时使用默认值。",
"047ec6b71d0cec08": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址",
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
"051836569928a9f9": "编辑",
"054d763265603305": "例如:278000(留空用默认值)",
"05c8a9238c702efa": "直连 Cursor 模式",
"0647728439b5da2e": "可配置运行模式和模型渠道;运行日志位于",
"092b520558eff5f2": "未测试",
"0b0e7478e41fe677": "{0} 的悬停提示不能为空",
"0c3b4cf7aa259edb": "操作失败",
"0dde813d719dbd01": "打开主页失败",
"0e40a09ab4e664ab": "模型渠道重复,请检查 url、modelID、apiKey、displayName 组合",
"1117a2f86030d03b": "缓存读写已计入 Prompt 侧统计。",
"11afd2a534395b18": "有效",
"124be3f86f197802": "Token 消耗",
"13b61c5f697b6700": "缓存命中率",
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
"18b7312022cd1840": "启动服务",
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
"1b7d24b212e52c54": "{0} 的推理强度仅支持 low、medium、high、xhigh",
"1bc77f5ab979f4c1": "新增模型配置",
"1e238093b79b3165": "留空时默认 278000",
"24343a2096988d42": "打开失败",
"253d4a3428c648fb": "缓存写入:{0}",
"258e4620e2108793": "留空时默认 4096",
"26a3855aed1d8d17": "服务未启动",
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
"2caeaec539e78898": "思考预算 Token",
"2cd0f3be8738a86c": "取消",
"2d706f7981b45a7b": "本地配置已保存",
"2f9daa828907b93f": "删除",
"30bb57a50caedc38": "例如:用于日常代码补全与问答",
"32b3c9a50003f77a": "输出 Token 为估算值",
"33d2d273e2bd5f88": "打开使用教程失败",
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
"35076178fe79a210": "配置已变更,请重新测试",
"36c149a9b3e8dca0": "模型。",
"37d23612f78a2e63": "立即重启更新",
"392d0dceb45998d3": "极高",
"393df9bb13ea4900": "命中",
"3af7e5489e61ea51": "刷新中",
"3bf8512aa520ed21": "本地服务模式",
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
"3d13868593ae4eeb": "界面语言",
"3ea83f9f55062582": "发布时间:{0}",
"3edda85621fd03b2": "个模型适配器",
"42aa8e01e98c0d8c": "总耗时",
"468adaa418ee1475": "例如:https://api.openai.com/v1",
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
"497c85690c4cc0fc": "暂无数据",
"4b8d11bf235e9213": "当前命中率:{0}",
"4d8c1c5b42830791": "未知",
"51194c3ad014fb29": "需重测",
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
"56627c94a9decee6": "最大输出 Token",
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
"5d1687a4a41883fd": "停止中...",
"6106f0a12583a334": "刷新失败",
"62873083fcaed27d": "会话统计",
"6309a3bb5ba4c714": "保存失败",
"636e3deffc1e960a": "模型 {0}",
"63d90d977348ab1f": "复制",
"64d2730f2ae37997": "原始返回",
"65cc5fd2e6ce6e75": "后端已启动,代理未启动",
"66af574b8948fe83": "{0} 的访问密钥不能为空",
"675109292da4eb36": "尚未测试",
"699fe7ade5407687": "直连模式",
"6a7b96f399e58138": "例如:sk-xxxxxx",
"6aa8f49cc992dfd7": "测试",
"6ae23d6d7cb18592": "服务错误",
"6e584e3d5ce64aa0": "保存配置",
"737225e2904673fc": "输出推算:{0}",
"7520bd50a5ee5471": "停止测试 {0}/{1}",
"753d8bb0da9913ce": "复制失败",
"77c9e582e85583af": "测试失败",
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
"7b6187c41e88b70c": "测试中...",
"7bf8e2c07e084d09": "模型编辑",
"7e9e334aeb0bdc07": "服务操作失败",
"7f68ebad19ba6bcd": "检查更新",
"81123c56d5d880d0": "访问密钥",
"86df7ec743047234": "服务运行中",
"87ed126f7bd1121e": "运行模式",
"899add6275682210": "留空时默认 500000",
"8c0d84831a3c3d5b": "当前为本地服务模式",
"8c1935935600e336": "模型测试",
"8cbcf741e727dbf7": "模型配置",
"8d1de152be6360ce": "有效占比:{0}",
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
"8f6f8d979c981ced": "已复制",
"8faa670b512b6b9b": "打开模型配置",
"917b1c1f18d0276b": "保存中...",
"9196835e388d2550": "测试全部",
"91cba5c107a51892": "/ 异常",
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
"93e08803675e378b": "模型标识",
"942ff2d88baca0c6": "检查更新中...",
"986678eccf56dc28": "服务状态更新中,请稍后再试",
"991e374fce0f4492": "Local-Cursor — Based on Cursor-BYOK",
"9970736b36ff2b68": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
"9b17fa889b307f7f": "有效轮次:{0}",
"9c38b6e9bf94abec": "已切换到直连 Cursor 模式",
"9cd4ac17428b86e4": "缓存命中率 = 缓存读取 Token / Prompt Token",
"9d2ca261281a158a": "稍后",
"9dc0825fba5422e4": "加载中...",
"a026f37e613cf48b": "输出 Token",
"a1a038dfa16c3ede": "当前已是最新版本(v{0})。",
"a3030bf8f16dc63c": "保存",
"a325d25c69e7256d": "模型配置不存在,无法复制",
"a4dd8bb7e8b6eb31": "显示访问密钥",
"a55a88237df85d98": "当前为直连模式",
"a567bdaa11367f26": "中",
"a5f1bd344c92e195": "调用该模型服务需要使用的访问密钥。",
"a693d69af48bfe48": "保存并测试",
"a98585871c5313ff": "显示名称",
"aa9e366f68d3d097": "低",
"ac217e4d1ca410f1": "发现新版本",
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
"ae5a738238463a92": "隐藏访问密钥",
"aed55419ce62f08e": "切换中...",
"b1c27820fec23edb": "高",
"b42049dcf8a05ef7": "已切换到本地服务模式",
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
"b765005f69fa971f": "例如:gpt-4.1",
"b90a8ac9c488ce46": "选择语言",
"ba40014ff496f64e": "类型",
"bb074b86a98f6911": "上下文窗口",
"bbacfde55a92869f": "命中率越高,说明重复上下文复用得越多。",
"bc87a4121a0873b3": "刷新统计",
"bd4464ea88d3f24a": "总轮次:{0}",
"bef280f9eb392495": "对话轮次",
"c228558cf257fc49": "删除失败",
"c3e9c3c60020b8b7": "选择模式",
"c69f5bce63b9f14c": "设置文件夹",
"c6f743953145f40b": "例如:4096(留空用默认值)",
"c8c14507b2d37395": "推理强度",
"c98e118e0a43f078": "模型",
"ca00a39fcea70dc6": "启动中...",
"ca1d1059408b3837": "异常轮次:{0}",
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
"d0325067fed88e5a": "缓存命中率 {0}",
"d08fd4224abcd69d": "切换失败",
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
"d20ab96566d33f25": "{0} 的显示名称不能为空",
"d2243e1d44b2a94e": "编辑模型配置",
"d3209b935ae86797": "模型配置不存在,无法删除",
"d373809ab86ba93b": "拷贝",
"d3b1da3088ddd334": "模型测试失败",
"d7da2aabd35772ec": "例如:500000(留空用默认值)",
"da590a8fe3ce4de0": "请选择",
"daede9881787abe7": "备注",
"dbee6e7139243362": "{0} 的接口地址不能为空",
"dc82c5e8fb2ab777": "版本:v{0}",
"de8184da1ef88d03": "已配置",
"e14c41ef2b7253c9": "总请求:{0}",
"e406825e0a72d2c2": "本地配置",
"e552c2accdbf5178": "新增模型",
"e6faccfddce722e8": "缓存读取:{0}",
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
"ec99e5c45d648fd6": "更新失败",
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
"f3a76d896853c1df": "未命中",
"f474a4108aba4c4c": "关闭服务",
"f56c6c82203b33f6": "提示",
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
"fac2a67ad87807c4": "确定",
"fb7a4c81729ed0ca": "关闭中...",
"fec45092945f8790": "使用教程"
}

View File

@@ -0,0 +1,185 @@
import { computed, ref } from "vue";
import {
DEFAULT_LOCALE,
LOCALE_OPTIONS,
LOCALE_STORAGE_KEY,
LOCALE_STORAGE_SOURCE_KEY,
SOURCE_LOCALE,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import zhCNMessages from "@/i18n/locales/zh-CN.json";
import enUSMessages from "@/i18n/locales/en-US.json";
import jaJPMessages from "@/i18n/locales/ja-JP.json";
const localeMessages = {
"zh-CN": zhCNMessages,
"en-US": enUSMessages,
"ja-JP": jaJPMessages,
};
const languageLocaleMap = {
zh: "zh-CN",
en: "en-US",
ja: "ja-JP",
};
function isSupportedLocale(locale) {
return SUPPORTED_LOCALES.includes(locale);
}
function matchSupportedLocale(locale) {
const normalized = String(locale || "").trim().replace(/_/g, "-");
if (!normalized) {
return "";
}
const lowered = normalized.toLowerCase();
const exactMatch = SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale.toLowerCase() === lowered);
if (exactMatch) {
return exactMatch;
}
const primaryLanguage = lowered.split("-")[0];
return languageLocaleMap[primaryLanguage] || "";
}
function getSystemLocaleCandidates() {
const candidates = [];
if (typeof navigator !== "undefined") {
if (Array.isArray(navigator.languages)) {
candidates.push(...navigator.languages);
}
candidates.push(navigator.language);
}
if (typeof Intl !== "undefined" && typeof Intl.DateTimeFormat === "function") {
candidates.push(Intl.DateTimeFormat().resolvedOptions()?.locale);
}
return candidates;
}
function resolveSystemLocale() {
for (const candidate of getSystemLocaleCandidates()) {
const matchedLocale = matchSupportedLocale(candidate);
if (matchedLocale) {
return matchedLocale;
}
}
return DEFAULT_LOCALE;
}
function resolveInitialLocale() {
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
return resolveSystemLocale();
}
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY);
const storedSource = window.localStorage.getItem(LOCALE_STORAGE_SOURCE_KEY);
if (storedSource === "manual") {
return matchSupportedLocale(storedLocale) || resolveSystemLocale();
}
window.localStorage.removeItem(LOCALE_STORAGE_KEY);
window.localStorage.removeItem(LOCALE_STORAGE_SOURCE_KEY);
return resolveSystemLocale();
}
function applyLocaleToDocument(locale) {
if (typeof document !== "undefined") {
document.documentElement.lang = locale;
}
}
function persistManualLocale(locale) {
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
window.localStorage.setItem(LOCALE_STORAGE_SOURCE_KEY, "manual");
}
function resolveMessage(id, fallback) {
const activeMessages = localeMessages[currentLocale.value] || {};
const sourceMessages = localeMessages[SOURCE_LOCALE] || {};
return activeMessages[id] || sourceMessages[id] || fallback || "";
}
function interpolateMessage(template, args = []) {
return template.replace(/\{(\d+)\}/g, (_match, index) => {
const value = args[Number(index)];
return value == null ? "" : String(value);
});
}
class LocalizedText extends String {
constructor(id, fallback, args = null) {
super(fallback);
this.id = id;
this.fallback = fallback;
this.args = args;
}
toString() {
const text = resolveMessage(this.id, this.fallback);
return Array.isArray(this.args) ? interpolateMessage(text, this.args) : text;
}
valueOf() {
return this.toString();
}
toJSON() {
return this.toString();
}
[Symbol.toPrimitive]() {
return this.toString();
}
}
const currentLocale = ref(resolveInitialLocale());
applyLocaleToDocument(currentLocale.value);
const localizedCache = new Map();
export function getLocale() {
return currentLocale.value;
}
export function setLocale(locale) {
const nextLocale = matchSupportedLocale(locale) || DEFAULT_LOCALE;
currentLocale.value = nextLocale;
persistManualLocale(nextLocale);
applyLocaleToDocument(nextLocale);
return nextLocale;
}
export function useLocale() {
return {
locale: currentLocale,
localeOptions: LOCALE_OPTIONS,
currentLocale: computed(() => currentLocale.value),
setLocale,
};
}
export function localized(id, fallback) {
const cacheKey = `${id}:${fallback}`;
if (!localizedCache.has(cacheKey)) {
localizedCache.set(cacheKey, new LocalizedText(id, fallback));
}
return localizedCache.get(cacheKey);
}
export function localizedTemplate(id, fallback, args = []) {
return new LocalizedText(id, fallback, args);
}
export function installI18nRuntime(app) {
app.config.globalProperties.$ls = localized;
app.config.globalProperties.$lt = localizedTemplate;
}

View File

@@ -0,0 +1,156 @@
<script setup>
import { Window } from "@wailsio/runtime";
import LocaleSelect from "@/components/LocaleSelect.vue";
import { appState, syncServiceState } from "@/state/appState";
import { isWindows } from "@/utils/isWindows";
import { computed, onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const title = computed(() => route.meta.title ?? "Local-Cursor");
const directlyClose = computed(() => route.meta.directlyClose === true);
const showFooter = computed(() => route.path === "/");
let proxyStateTimer = null;
const proxyStatePollIntervalMs = 10000;
const netProxyEndpoint = computed(
() => appState.netProxyHttps || appState.netProxyHttp || "",
);
const proxyBadgeText = computed(() => {
if (appState.netProxyUsingSystem) {
return "已识别系统代理";
}
return "";
});
const proxyBadgeTitle = computed(() => {
if (appState.netProxyUsingSystem) {
return netProxyEndpoint.value
? `当前出站请求使用系统代理:${netProxyEndpoint.value}`
: "当前出站请求使用系统代理";
}
if (appState.netProxyUsingEnv) {
return netProxyEndpoint.value
? `当前出站请求使用环境变量代理:${netProxyEndpoint.value}`
: "当前出站请求使用环境变量代理";
}
if (appState.netProxyPacIgnored) {
return "检测到系统 PAC/自动代理,当前版本按直连处理";
}
return "当前出站请求未使用系统代理";
});
async function minimizeWindow() {
await Window.Minimise();
}
async function closeWindow() {
if (directlyClose.value) {
await Window.Close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 200));
await Window.Hide();
}
onMounted(() => {
proxyStateTimer = window.setInterval(() => {
if (showFooter.value) {
void syncServiceState().catch(() => {});
}
}, proxyStatePollIntervalMs);
});
onUnmounted(() => {
if (proxyStateTimer) {
window.clearInterval(proxyStateTimer);
proxyStateTimer = null;
}
});
</script>
<template>
<div class="flex h-screen w-screen overflow-hidden">
<!-- drag region for frameless window -->
<div
class="fixed top-0 left-0 h-[40px] z-9999"
style="--wails-draggable: drag; width: 52px;"
></div>
<!-- ═══ SIDEBAR ═══ -->
<nav class="flex flex-col items-center w-[52px] shrink-0 bg-[#0a0a0a] border-r border-[#2a2a2a] pt-[12px] gap-[4px] z-50">
<div class="flex items-center justify-center w-[28px] h-[28px] border-[1.5px] border-white rounded-[6px] font-extrabold text-[13px] tracking-tight mb-[16px]"
style="--wails-draggable: no-drag"
>
LC
</div>
<div class="flex-1"></div>
<!-- nav buttons handled by router -->
<!-- bottom: language + version -->
<div class="flex flex-col items-center gap-[6px] pb-[10px]">
<LocaleSelect
compact
:border="false"
aria-label="界面语言"
wrapper-class="w-full"
button-class="!h-[24px] !w-full !bg-transparent !px-1 !text-[12px] !text-[#666] hover:!text-[#fff]"
menu-class="text-[12px] min-w-[120px]"
/>
<span v-if="appState.appVersion" class="text-[10px] tabular-nums text-[#444] leading-none">
v{{ appState.appVersion }}
</span>
</div>
</nav>
<!-- ═══ MAIN ═══ -->
<div class="flex flex-1 min-w-0 flex-col">
<!-- topbar -->
<header
class="flex h-[40px] shrink-0 items-center justify-between px-[16px] bg-black border-b border-[#2a2a2a] relative"
style="--wails-draggable: drag"
>
<span class="font-semibold text-[13px] tracking-wide">{{ title }}</span>
<div
v-if="isWindows"
class="absolute right-[10px] top-[8px] flex items-center gap-[1px] z-99999"
>
<button
class="flex items-center justify-center w-[30px] h-[23px] rounded-[4px] text-[#666] hover:bg-[#1c1c1c] hover:text-white cursor-pointer"
@click="minimizeWindow"
>
<span class="icon-[ic--round-minus] text-[20px]"></span>
</button>
<button
class="flex items-center justify-center w-[30px] h-[23px] rounded-[4px] text-[#666] hover:bg-[#1c1c1c] hover:text-white cursor-pointer"
@click="closeWindow"
>
<span class="icon-[ic--round-close] text-[20px]"></span>
</button>
</div>
</header>
<!-- content -->
<main class="flex-1 min-h-0 overflow-hidden flex flex-col">
<router-view />
</main>
<!-- statusbar (only on home) -->
<footer
v-if="showFooter"
class="flex h-[28px] shrink-0 items-center justify-between px-[12px] border-t border-[#2a2a2a] bg-[#0a0a0a] text-[10px] text-[#666]"
>
<div class="flex items-center gap-[12px]">
<span>{{ appState.serviceRunning ? "● 服务运行中" : "● 服务已停止" }}</span>
</div>
<div class="flex items-center gap-[12px]">
<span
v-if="proxyBadgeText"
class="flex items-center gap-[2px]"
:title="proxyBadgeTitle"
>
<span class="icon-[mdi--wifi] text-[15px]"></span>
<span class="truncate">{{ proxyBadgeText }}</span>
</span>
</div>
</footer>
</div>
</div>
</template>

40
frontend/src/main.js Normal file
View File

@@ -0,0 +1,40 @@
import { createApp } from "vue";
import ResizeObserver from "resize-observer-polyfill";
import App from "@/App.vue";
import { installI18nRuntime } from "@/i18n/runtime";
import router from "@/router";
import { bootstrapAppState } from "@/state/appState";
import "@/style/global.css";
import "@/style/tailwind.css";
if (typeof window !== "undefined" && typeof window.ResizeObserver === "undefined") {
window.ResizeObserver = ResizeObserver;
}
function updateFlexGapSupportClass() {
if (typeof document === "undefined" || !document.body) {
return;
}
const flex = document.createElement("div");
flex.style.position = "absolute";
flex.style.visibility = "hidden";
flex.style.display = "flex";
flex.style.flexDirection = "column";
flex.style.rowGap = "1px";
flex.appendChild(document.createElement("div"));
flex.appendChild(document.createElement("div"));
document.body.appendChild(flex);
document.documentElement.classList.toggle("no-flex-gap", flex.scrollHeight !== 1);
flex.parentNode?.removeChild(flex);
}
updateFlexGapSupportClass();
const app = createApp(App);
installI18nRuntime(app);
app.use(router);
app.mount("#root");
bootstrapAppState().catch(() => {
// 启动阶段失败时保持界面可用,错误在业务交互中再提示。
});

View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue";
import ModelConfig from "@/views/ModelConfig.vue";
import ModelEditor from "@/views/ModelEditor.vue";
import Skills from "@/views/Skills.vue";
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/",
component: Home,
meta: { showIcon: true, title: "Local-Cursor", directlyClose: false },
},
{
path: "/model-config",
component: ModelConfig,
meta: { showIcon: false, title: "模型配置", directlyClose: true },
},
{
path: "/model-editor",
component: ModelEditor,
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
},
{
path: "/skills",
component: Skills,
meta: { showIcon: false, title: "Skill 管理", directlyClose: true },
},
],
});
export default router;

View File

@@ -0,0 +1,185 @@
import {
GetState,
LoadUserConfig,
SaveUserConfig,
StartProxy,
StopProxy,
} from "@bindings/cursor/internal/bridge/proxyservice.js";
import { GetHomeMetricsSummary } from "@bindings/cursor/internal/bridge/metricsservice.js";
import {
GetAppVersion,
GetCursorStatus,
GetModelEditorContext,
LocalizeCursor,
OpenCLITerminal,
OpenConfigWindow,
OpenCursor,
OpenHistoryWindow,
OpenModelConfigWindow,
OpenModelEditorWindow,
OpenSkillWindow,
SelectDirectory,
} from "@bindings/cursor/internal/bridge/windowservice.js";
import {
DeleteProjectRecord,
GetProjectSkillStates,
ListRecentProjects,
ListSkills,
SetProjectSkill,
} from "@bindings/cursor/internal/bridge/skillservice.js";
import { Call } from "@wailsio/runtime";
const API_LOG_PREFIX = "[clientApi]";
const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService";
function logSuccess(name, payload, result) {
console.log(`${API_LOG_PREFIX} ${name} response`, {
payload,
result,
});
}
function logError(name, payload, error) {
console.error(`${API_LOG_PREFIX} ${name} error`, {
payload,
error,
});
}
function withApiLogging(name, payload, runner) {
return Promise.resolve()
.then(() => runner())
.then((result) => {
logSuccess(name, payload, result);
return result;
})
.catch((error) => {
logError(name, payload, error);
throw error;
});
}
export function loadUserConfig() {
return withApiLogging("LoadUserConfig", undefined, () => LoadUserConfig());
}
export function saveUserConfig(payload) {
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
}
export function getProxyState() {
return withApiLogging("GetState", undefined, () => GetState());
}
export function getHomeMetricsSummary() {
return withApiLogging("GetHomeMetricsSummary", undefined, () => GetHomeMetricsSummary());
}
export function startProxyService() {
return withApiLogging("StartProxy", undefined, () => StartProxy());
}
export function stopProxyService() {
return withApiLogging("StopProxy", undefined, () => StopProxy());
}
export function openLogsDirectory() {
return withApiLogging("OpenHistoryWindow", undefined, () => OpenHistoryWindow());
}
export function openConfigWindow() {
return withApiLogging("OpenConfigWindow", undefined, () => OpenConfigWindow());
}
export function getAppVersion() {
return withApiLogging("GetAppVersion", undefined, () => GetAppVersion());
}
export function openModelConfig() {
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow());
}
export function openModelEditor(index, adapterJSON) {
return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
OpenModelEditorWindow(index, adapterJSON),
);
}
export function openCLITerminal() {
return withApiLogging("OpenCLITerminal", undefined, () => OpenCLITerminal());
}
export function localizeCursor(installDir = "") {
return withApiLogging("LocalizeCursor", { installDir }, () => LocalizeCursor(installDir));
}
export function selectDirectory() {
return withApiLogging("SelectDirectory", undefined, () => SelectDirectory());
}
export function getCursorStatus() {
return withApiLogging("GetCursorStatus", undefined, () => GetCursorStatus());
}
export function openCursor(installDir = "") {
return withApiLogging("OpenCursor", { installDir }, () => OpenCursor(installDir));
}
export function openSkillWindow() {
return withApiLogging("OpenSkillWindow", undefined, () => OpenSkillWindow());
}
export function listRecentProjects() {
return withApiLogging("ListRecentProjects", undefined, () => ListRecentProjects());
}
export function deleteProjectRecord(projectPath) {
return withApiLogging("DeleteProjectRecord", { projectPath }, () =>
DeleteProjectRecord(projectPath),
);
}
export function listSkills() {
return withApiLogging("ListSkills", undefined, () => ListSkills());
}
export function getProjectSkillStates(projectPath) {
return withApiLogging("GetProjectSkillStates", { projectPath }, () =>
GetProjectSkillStates(projectPath),
);
}
export function setProjectSkill(projectPath, skillName, enabled) {
return withApiLogging("SetProjectSkill", { projectPath, skillName, enabled }, () =>
SetProjectSkill(projectPath, skillName, enabled),
);
}
export function getModelEditorContext() {
return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
}
export function testModelAdapter(adapter) {
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
(result) => {
logSuccess("TestModelAdapter", adapter, result);
return result;
},
(error) => {
logError("TestModelAdapter", adapter, error);
throw error;
},
);
}
export function listRemoteModels(adapterType, baseURL, apiKey) {
return withApiLogging("ListRemoteModels", { adapterType, baseURL }, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.ListRemoteModels`, adapterType, baseURL, apiKey),
);
}
export function getModelAdapterTestResults() {
return withApiLogging("GetModelAdapterTestResults", undefined, () =>
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
);
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,188 @@
@font-face {
font-family: "PingFang-Medium";
src: url("./fonts/PingFang-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
html,
body,
#root {
margin: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
background: #000000;
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #ffffff;
font-size: 13px;
}
* {
box-sizing: border-box;
}
/* Global native scrollbar style: thin thumb, transparent track */
* {
scrollbar-width: thin;
scrollbar-color: rgba(80, 80, 80, 0.8) transparent;
}
:root{
color-scheme: dark;
}
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-track-piece,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(80, 80, 80, 0.8);
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(120, 120, 120, 0.9);
}
/* macOS overlay scrollbar style */
@supports (scrollbar-width: thin) {
:root {
scrollbar-width: thin;
}
}
/* Prevent layout shift when scrollbar appears */
.overflow-auto,
.overflow-scroll {
scrollbar-gutter: stable;
}
.no-flex-gap .flex.gap-1:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-1:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-1 > * + * {
margin-left: 0.25rem;
}
.no-flex-gap .flex.flex-col.gap-1 > * + *,
.no-flex-gap .center-col.gap-1 > * + * {
margin-top: 0.25rem;
}
.no-flex-gap .flex.gap-1\.5:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-1\.5:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-1\.5 > * + * {
margin-left: 0.375rem;
}
.no-flex-gap .flex.flex-col.gap-1\.5 > * + *,
.no-flex-gap .center-col.gap-1\.5 > * + * {
margin-top: 0.375rem;
}
.no-flex-gap .flex.gap-2:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-2:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-2 > * + * {
margin-left: 0.5rem;
}
.no-flex-gap .flex.flex-col.gap-2 > * + *,
.no-flex-gap .center-col.gap-2 > * + * {
margin-top: 0.5rem;
}
.no-flex-gap .flex.gap-2\.5:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-2\.5:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-2\.5 > * + * {
margin-left: 0.625rem;
}
.no-flex-gap .flex.flex-col.gap-2\.5 > * + *,
.no-flex-gap .center-col.gap-2\.5 > * + * {
margin-top: 0.625rem;
}
.no-flex-gap .flex.gap-3:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-3:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-3 > * + * {
margin-left: 0.75rem;
}
.no-flex-gap .flex.flex-col.gap-3 > * + *,
.no-flex-gap .center-col.gap-3 > * + * {
margin-top: 0.75rem;
}
.no-flex-gap .flex.gap-4:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-4:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-4 > * + * {
margin-left: 1rem;
}
.no-flex-gap .flex.flex-col.gap-4 > * + *,
.no-flex-gap .center-col.gap-4 > * + * {
margin-top: 1rem;
}
.no-flex-gap .flex.gap-\[1px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[1px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[1px\] > * + * {
margin-left: 1px;
}
.no-flex-gap .flex.flex-col.gap-\[1px\] > * + *,
.no-flex-gap .center-col.gap-\[1px\] > * + * {
margin-top: 1px;
}
.no-flex-gap .flex.gap-\[2px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[2px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[2px\] > * + * {
margin-left: 2px;
}
.no-flex-gap .flex.flex-col.gap-\[2px\] > * + *,
.no-flex-gap .center-col.gap-\[2px\] > * + * {
margin-top: 2px;
}
.no-flex-gap .flex.gap-\[6px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[6px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[6px\] > * + * {
margin-left: 6px;
}
.no-flex-gap .flex.flex-col.gap-\[6px\] > * + *,
.no-flex-gap .center-col.gap-\[6px\] > * + * {
margin-top: 6px;
}
.no-flex-gap .flex.gap-\[8px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[8px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[8px\] > * + * {
margin-left: 8px;
}
.no-flex-gap .flex.flex-col.gap-\[8px\] > * + *,
.no-flex-gap .center-col.gap-\[8px\] > * + * {
margin-top: 8px;
}
.no-flex-gap .flex.gap-\[10px\]:not(.flex-col) > * + *,
.no-flex-gap .inline-flex.gap-\[10px\]:not(.flex-col) > * + *,
.no-flex-gap .center-row.gap-\[10px\] > * + * {
margin-left: 10px;
}
.no-flex-gap .flex.flex-col.gap-\[10px\] > * + *,
.no-flex-gap .center-col.gap-\[10px\] > * + * {
margin-top: 10px;
}

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "HFKos";
src: url("./fonts/HFKos-R.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
@layer base {
:root {
--font-num: "HFKos", "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
}
@layer components {
.center-row {
@apply flex flex-row items-center;
}
.center-col {
@apply flex flex-col items-center;
}
}

View File

@@ -0,0 +1,4 @@
import { IsWindows } from "@bindings/cursor/internal/bridge/proxyservice.js";
import { ref } from "vue";
export const isWindows = ref(Boolean(await IsWindows()));

View File

@@ -0,0 +1,39 @@
const INTEGER_FORMATTER = new Intl.NumberFormat("en-US");
const COMPACT_UNITS = [
{ value: 1000000000000, suffix: "T" },
{ value: 1000000000, suffix: "B" },
{ value: 1000000, suffix: "M" },
{ value: 1000, suffix: "K" },
];
function normalizeInteger(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function trimTrailingZeros(text) {
return text.replace(/\.0$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
}
export function formatInteger(value) {
return INTEGER_FORMATTER.format(normalizeInteger(value));
}
export function formatCompactInteger(value) {
const number = normalizeInteger(value);
const absNumber = Math.abs(number);
const unit = COMPACT_UNITS.find(({ value: threshold }) => absNumber >= threshold);
if (!unit) {
return formatInteger(number);
}
const scaled = number / unit.value;
const fractionDigits = Math.abs(scaled) < 100 ? 1 : 0;
return `${trimTrailingZeros(scaled.toFixed(fractionDigits))}${unit.suffix}`;
}

315
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,315 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Switch from "@/components/ui/Switch.vue";
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
import { useMessage } from "@/composables/useMessage";
import { showModal } from "@/composables/useModal";
import {
appState,
appViewState,
openConfigWindow,
openModelConfigWindow,
saveRoutingMode,
syncHomeMetrics,
syncServiceState,
toUserError,
toggleService,
} from "@/state/appState";
import {
getCursorStatus,
localizeCursor,
openCLITerminal,
openCursor,
openSkillWindow,
selectDirectory,
} from "@/services/clientApi";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
const directModeEnabled = computed(() => appState.routingMode === "upstream");
const message = useMessage();
const cliTerminalBusy = ref(false);
const localizing = ref(false);
// ── Cursor 连接状态(心跳检测) ──
const cursorStatus = reactive({
loading: true,
installFound: false,
installPath: "",
processRunning: false,
});
const cursorStatusTimer = ref(null);
const cursorHeartbeatMs = 15000;
async function syncCursorStatus() {
try {
const status = await getCursorStatus();
cursorStatus.installFound = Boolean(status?.installFound);
cursorStatus.installPath = String(status?.installPath || "");
cursorStatus.processRunning = Boolean(status?.processRunning);
} catch {
// 心跳失败不打断界面,保留上次状态。
} finally {
cursorStatus.loading = false;
}
}
async function handleOpenCursor() {
try {
await openCursor(cursorStatus.installFound ? cursorStatus.installPath : "");
// 与心跳错峰,立即刷新一次进程状态。
void syncCursorStatus();
} catch (error) {
await showActionError("启动失败", toUserError(error));
}
}
async function handlePickCursorDir() {
try {
const dir = await selectDirectory();
if (!dir) {
return;
}
cursorStatus.installPath = dir;
cursorStatus.installFound = true;
message.success("已手动指定 Cursor 安装目录");
} catch (error) {
await showActionError("选择目录失败", toUserError(error));
}
}
onUnmounted(() => {
if (cursorStatusTimer.value) {
window.clearInterval(cursorStatusTimer.value);
}
});
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
async function handleToggleService() {
const result = await toggleService();
if (!result.ok) {
await showActionError("服务操作失败", result.error);
}
}
async function handleRefreshState() {
const [serviceStateResult] = await Promise.allSettled([
syncServiceState(),
syncHomeMetrics(),
]);
if (serviceStateResult.status === "rejected") {
await showActionError("刷新失败", toUserError(serviceStateResult.reason));
}
}
async function handleRefreshMetrics() {
await syncHomeMetrics().catch(() => {});
}
async function handleOpenConfig() {
try {
await openConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleOpenModelConfig() {
try {
await openModelConfigWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleDirectModeChange(enabled) {
const result = await saveRoutingMode(enabled ? "upstream" : "local");
if (!result.ok) {
await showActionError("切换失败", result.error);
return;
}
message.success(enabled ? "已切换到直连 Cursor 模式" : "已切换到本地服务模式");
}
async function handleOpenCLITerminal() {
if (cliTerminalBusy.value) {
return;
}
cliTerminalBusy.value = true;
try {
const result = await openCLITerminal();
if (!result?.ok) {
await showActionError("CLI 终端", result?.error || "打开失败");
return;
}
message.success("CLI 终端已打开,请在终端中 cd 到项目目录后运行 cursor-agent");
} catch (error) {
await showActionError("CLI 终端", toUserError(error));
} finally {
cliTerminalBusy.value = false;
}
}
async function handleOpenSkillWindow() {
try {
await openSkillWindow();
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleLocalizeCursor() {
if (localizing.value) {
return;
}
const confirmed = await showModal({
title: "汉化 Cursor",
content: "将关闭所有 Cursor 进程并运行社区汉化包,未保存的工作可能丢失。是否继续?",
confirmText: "继续",
});
if (!confirmed) {
return;
}
localizing.value = true;
try {
// 优先使用状态卡片中已知的目录(含手动指定),否则走后端自动检测。
const output = await localizeCursor(cursorStatus.installFound ? cursorStatus.installPath : "");
message.success(typeof output === "string" && output.trim() ? output.trim() : "汉化完成,请重新启动 Cursor");
} catch (error) {
await showActionError("汉化失败", toUserError(error));
} finally {
localizing.value = false;
}
}
onMounted(() => {
void handleRefreshState();
void syncCursorStatus();
cursorStatusTimer.value = window.setInterval(() => {
void syncCursorStatus();
}, cursorHeartbeatMs);
});
</script>
<template>
<div class="flex flex-col gap-[12px] p-[16px] text-white overflow-hidden h-full">
<!-- cursor connection status -->
<Card>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<span
class="w-[10px] h-[10px] rounded-full shrink-0"
:class="cursorStatus.loading ? 'bg-[#666] animate-pulse' : cursorStatus.installFound ? 'bg-[#22c55e]' : 'bg-[#ff4444]'"
/>
<div class="min-w-0">
<div class="text-[14px] font-medium">
{{ cursorStatus.loading ? "正在检测 Cursor..." : cursorStatus.installFound ? "Cursor 已连接" : "未找到 Cursor 安装目录" }}
</div>
<div class="text-[12px] text-[#a8a8a8] truncate" :title="cursorStatus.installPath">
{{ cursorStatus.installPath || "未检测到安装路径,可手动指定" }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="rounded-[999px] border px-[10px] py-[4px] text-[11px]"
:class="cursorStatus.processRunning
? 'border-[#22c55e]/40 text-[#22c55e]'
: 'border-[#666] text-[#a8a8a8]'"
>
{{ cursorStatus.processRunning ? "Cursor 运行中" : "Cursor 未运行" }}
</span>
<Button variant="default" :disabled="!cursorStatus.installFound" @click="handleOpenCursor">
打开 Cursor
</Button>
<Button variant="default" :disabled="cursorStatus.loading" @click="handlePickCursorDir">
手动指定
</Button>
</div>
</div>
</Card>
<!-- service status -->
<Card>
<div class="flex flex-col gap-[12px]">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-sm" :class="appViewState.serviceStatusClass">
{{ appViewState.serviceStatusText }}
</div>
</div>
<Button variant="primary" :disabled="appState.serviceBusy" @click="handleToggleService">
<span class="icon-[mdi--pause] text-[16px]" v-if="appState.serviceRunning"></span>
<span class="icon-[mdi--play] text-[16px]" v-else></span>
<span> {{ appViewState.serviceButtonText }}</span>
</Button>
</div>
<div v-if="appState.serviceLastError"
class="rounded-[4px] border border-[#ff4444]/30 bg-[#ff4444]/5 px-3 py-2 text-sm text-[#ff4444]"
>
{{ appState.serviceLastError }}
</div>
<Switch
label="直连模式"
description="开启后,Cursor将直接接通官方,请勿开启"
enabled-text="当前为直连模式"
disabled-text="当前为本地服务模式"
:enabled="directModeEnabled"
:busy="appState.configSaving"
:disabled="appState.configSaving"
@change="handleDirectModeChange"
/>
</div>
</Card>
<!-- metrics -->
<HomeMetricsCard
:metrics="appState.homeMetrics"
:loading="appState.homeMetricsLoading"
:error="appState.homeMetricsError"
@refresh="handleRefreshMetrics"
/>
<!-- quick actions -->
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">本地配置</h2>
<div class="text-[12px] text-[#a8a8a8]">打开设置目录,或单独管理模型配置</div>
</div>
<div class="flex items-center gap-2">
<Button variant="default" @click="handleOpenConfig">设置文件夹</Button>
<Button variant="default" @click="handleOpenSkillWindow">Skill 管理</Button>
<Button variant="default" :disabled="localizing" @click="handleLocalizeCursor">
{{ localizing ? "汉化中..." : "汉化 Cursor" }}
</Button>
<Button variant="primary" @click="handleOpenModelConfig">模型配置</Button>
</div>
</div>
</Card>
<!-- cli terminal -->
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">CLI 终端</h2>
<div class="text-[12px] text-[#a8a8a8]">以管理员权限打开终端,预配置 wrapper 环境变量</div>
</div>
<div class="flex items-center gap-2">
<Button variant="default" :disabled="cliTerminalBusy" @click="handleOpenCLITerminal">
<span class="icon-[mdi--console-line] text-[16px]"></span>
<span>{{ cliTerminalBusy ? "打开中..." : "打开终端" }}</span>
</Button>
</div>
</div>
</Card>
</div>
</template>

View File

@@ -0,0 +1,484 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import { showModal } from "@/composables/useModal";
import {
appState,
batchAddModelAdapters,
createEmptyModelAdapter,
deleteModelAdapterAt,
duplicateModelAdapterAt,
fetchRemoteModels,
getModelAdapterTestResultByID,
openModelEditorWindow,
reloadUserConfig,
runModelAdapterTest,
startModelAdapterTest,
toUserError,
} from "@/state/appState";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
const BATCH_TEST_CONCURRENCY = 10;
const fetchModal = reactive({
visible: false,
loading: false,
error: "",
baseURL: "",
apiKey: "",
models: [],
selected: new Set(),
adding: false,
});
const fetchSelectedCount = computed(() => fetchModal.selected.size);
function openFetchModal() {
const existing = appState.modelAdapters.find((adapter) => adapter.type === activeType.value);
fetchModal.baseURL = existing?.baseURL || "";
fetchModal.apiKey = existing?.apiKey || "";
fetchModal.models = [];
fetchModal.selected = new Set();
fetchModal.error = "";
fetchModal.visible = true;
}
function closeFetchModal() {
if (fetchModal.loading || fetchModal.adding) {
return;
}
fetchModal.visible = false;
}
function toggleFetchModel(modelID) {
const next = new Set(fetchModal.selected);
if (next.has(modelID)) {
next.delete(modelID);
} else {
next.add(modelID);
}
fetchModal.selected = next;
}
function toggleFetchAll() {
const allSelected = fetchModal.models.length > 0 && fetchModal.selected.size === fetchModal.models.length;
fetchModal.selected = allSelected ? new Set() : new Set(fetchModal.models.map((model) => model.id));
}
async function handleFetchModels() {
fetchModal.error = "";
fetchModal.loading = true;
try {
const models = await fetchRemoteModels(activeType.value, fetchModal.baseURL, fetchModal.apiKey);
fetchModal.models = models.map((id) => ({ id, selected: true }));
fetchModal.selected = new Set(models);
} catch (error) {
fetchModal.error = String(toUserError(error)?.message || error || "拉取失败");
} finally {
fetchModal.loading = false;
}
}
async function handleBatchAddModels() {
if (fetchModal.selected.size === 0) {
return;
}
fetchModal.error = "";
fetchModal.adding = true;
try {
const result = await batchAddModelAdapters(
fetchModal.models
.filter((model) => fetchModal.selected.has(model.id))
.map((model) => ({
type: activeType.value,
baseURL: fetchModal.baseURL,
apiKey: fetchModal.apiKey,
modelID: model.id,
displayName: model.id,
})),
);
if (!result.ok) {
fetchModal.error = result.error || "保存失败";
return;
}
fetchModal.visible = false;
} catch (error) {
fetchModal.error = String(toUserError(error)?.message || error || "保存失败");
} finally {
fetchModal.adding = false;
}
}
const typeTabs = [
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
];
const activeType = ref("openai");
const batchTesting = ref(false);
const batchStopping = ref(false);
const batchTotal = ref(0);
const batchCompleted = ref(0);
const batchActiveCalls = new Set();
let batchStopRequested = false;
const filteredAdapters = computed(() =>
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value),
);
const batchButtonText = computed(() => {
if (batchStopping.value) {
return "停止中...";
}
if (!batchTesting.value) {
return "测试全部";
}
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
});
watch(
() => appState.modelAdapters,
(adapters) => {
if (adapters.some((adapter) => adapter.type === activeType.value)) {
return;
}
const fallback = typeTabs.find((tab) => adapters.some((adapter) => adapter.type === tab.value));
activeType.value = fallback?.value ?? "openai";
},
{ deep: true, immediate: true },
);
async function showActionError(title, error) {
await showModal({
title,
content: String(error || "服务错误").trim() || "服务错误",
});
}
function maskSecret(value) {
const text = String(value || "").trim();
if (!text) {
return "-";
}
if (text.length <= 8) {
return `${"*".repeat(Math.max(text.length - 2, 0))}${text.slice(-2)}`;
}
return `${text.slice(0, 4)}****${text.slice(-4)}`;
}
function typeLabel(type) {
return type === "anthropic" ? "Anthropic" : "OpenAI";
}
function formatHost(value) {
const text = String(value || "").trim();
if (!text) {
return "-";
}
try {
const parsed = new URL(text);
return parsed.host || text;
} catch {
return text.replace(/^https?:\/\//, "");
}
}
async function openEditor(index = -1) {
const adapter = index >= 0
? appState.modelAdapters[index]
: {
...createEmptyModelAdapter(),
type: activeType.value,
};
try {
await openModelEditorWindow(index, adapter);
} catch (error) {
await showActionError("打开失败", toUserError(error));
}
}
async function handleDeleteModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("删除失败", "模型配置不存在,无法删除");
return;
}
const result = await deleteModelAdapterAt(index);
if (!result.ok) {
await showActionError("删除失败", result.error);
}
}
async function handleDuplicateModelAdapter(index) {
const target = appState.modelAdapters[index];
if (!target) {
await showActionError("复制失败", "模型配置不存在,无法复制");
return;
}
const result = await duplicateModelAdapterAt(index);
if (!result.ok) {
await showActionError("复制失败", result.error);
}
}
function getAdapterTestResult(adapter) {
return getModelAdapterTestResultByID(adapter?.id);
}
function isAdapterTesting(adapter) {
return getAdapterTestResult(adapter)?.status === "running";
}
async function handleTestModelAdapter(adapter) {
try {
await runModelAdapterTest(adapter);
} catch (_error) {
// 失败结果会通过事件同步到界面,这里不再额外弹窗打断用户。
}
}
function isCancelError(error) {
return String(error?.name || "").trim() === "CancelError";
}
async function stopBatchTesting() {
if (!batchTesting.value || batchStopping.value) {
return;
}
batchStopRequested = true;
batchStopping.value = true;
const activeCalls = Array.from(batchActiveCalls);
await Promise.allSettled(
activeCalls.map((call) => (typeof call?.cancel === "function" ? call.cancel("batch-stop") : undefined)),
);
}
async function handleTestAllModelAdapters() {
if (batchTesting.value) {
await stopBatchTesting();
return;
}
const adapters = filteredAdapters.value.slice();
if (adapters.length === 0) {
return;
}
batchStopRequested = false;
batchTesting.value = true;
batchStopping.value = false;
batchTotal.value = adapters.length;
batchCompleted.value = 0;
let nextIndex = 0;
try {
const workers = Array.from({ length: Math.min(BATCH_TEST_CONCURRENCY, adapters.length) }, async () => {
while (!batchStopRequested) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= adapters.length) {
return;
}
const adapter = adapters[currentIndex];
const call = startModelAdapterTest(adapter);
batchActiveCalls.add(call);
try {
await call;
} catch (error) {
if (!isCancelError(error) && !batchStopRequested) {
// 单个失败结果由卡片自行展示,这里继续后续测试。
}
} finally {
batchActiveCalls.delete(call);
batchCompleted.value += 1;
}
}
});
await Promise.allSettled(workers);
} finally {
batchActiveCalls.clear();
batchStopRequested = false;
batchTesting.value = false;
batchStopping.value = false;
}
}
onMounted(async () => {
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
});
onBeforeUnmount(() => {
void stopBatchTesting();
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col p-4 text-white overflow-hidden">
<div class="shrink-0 pb-4">
<div class="flex items-center justify-between gap-4">
<div class="center-row gap-2">
<button
v-for="tab in typeTabs"
:key="tab.value"
type="button"
class="center-row gap-2 rounded-[8px] border px-3 py-2 text-sm transition-colors duration-150"
:class="activeType === tab.value
? 'border-white bg-white/10 text-white'
: 'border-[#2a2a2a] bg-[#141414] text-[#a8a8a8] hover:border-[#666] hover:text-white'"
@click="activeType = tab.value"
>
<span :class="[tab.icon, 'text-[16px]']"></span>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="center-row gap-2">
<Button
variant="default"
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
@click="handleTestAllModelAdapters"
>
{{ batchButtonText }}
</Button>
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || fetchModal.visible"
@click="openFetchModal"
>
拉取模型
</Button>
<Button variant="primary" :disabled="appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
</div>
</div>
</div>
<div class="min-h-0 flex-1">
<div v-if="filteredAdapters.length === 0"
class="flex h-full min-h-[220px] items-center justify-center rounded-[4px] border border-dashed border-[#2a2a2a] bg-[#141414] px-4 text-sm text-[#a8a8a8]">
当前还没有配置任何 {{ typeLabel(activeType) }} 模型。
</div>
<div v-else class="h-full min-h-0 overflow-y-auto pr-1">
<div class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]">
<Card
v-for="(adapter, index) in filteredAdapters"
:key="adapter.id || `${adapter.baseURL}-${adapter.modelID}-${index}`"
>
<div class="flex h-full min-h-[154px] flex-col justify-between gap-3">
<div class="flex flex-col gap-2.5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-base font-medium text-white">{{ adapter.displayName }}</div>
<div class="mt-1 truncate text-sm text-[#666]">{{ adapter.modelID }}</div>
<div v-if="adapter.type === 'openai'" class="mt-0.5 truncate text-xs text-[#666]">
{{ adapter.openAIEndpoint || "/v1/responses" }}
</div>
</div>
<span
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#383838] px-[7px] py-[4px] text-[11px] font-medium text-white"
>
<span class="icon-[bxl--openai] text-[14px] !text-white" v-if="adapter.type === 'openai'"></span>
<span class="icon-[logos--claude-icon] text-[14px]" v-else></span>
<span>{{ typeLabel(adapter.type) }}</span>
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-[#a8a8a8]">
<div class="rounded-[4px] bg-[#141414] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">Host</div>
<div class="mt-1 truncate text-white" :title="adapter.baseURL">{{ formatHost(adapter.baseURL) }}</div>
</div>
<div class="rounded-[4px] bg-[#141414] px-3 py-2">
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">API Key</div>
<div class="mt-1 truncate text-white">{{ maskSecret(adapter.apiKey) }}</div>
</div>
</div>
<ModelAdapterTestCard
compact
title="测试"
empty-text="未测试"
:result="getAdapterTestResult(adapter)"
/>
</div>
<div class="center-row flex-wrap justify-end gap-2 border-t border-[#2a2a2a] pt-3">
<Button
variant="default"
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)"
@click="handleTestModelAdapter(adapter)"
>
{{ isAdapterTesting(adapter) ? "测试中..." : "测试" }}
</Button>
<Button variant="default" :disabled="appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
<Button variant="default" :disabled="appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
<Button variant="text" :disabled="appState.configSaving"
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
<Teleport to="body">
<div v-if="fetchModal.visible" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4" @click.self="closeFetchModal">
<div class="flex max-h-[80vh] w-full max-w-[520px] flex-col rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-4 text-white">
<div class="flex items-center justify-between pb-3">
<span class="text-base font-medium">拉取 {{ typeLabel(activeType) }} 模型列表</span>
<button type="button" class="text-[#a8a8a8] hover:text-white" :disabled="fetchModal.loading || fetchModal.adding" @click="closeFetchModal">✕</button>
</div>
<div class="flex flex-col gap-2 pb-3">
<input
v-model="fetchModal.baseURL"
type="text"
placeholder="接口地址,例如 https://api.example.com"
class="h-8 rounded-[4px] border border-[#383838] bg-[#0f0f0f] px-2 text-sm outline-none focus:border-white"
:disabled="fetchModal.loading || fetchModal.adding"
/>
<input
v-model="fetchModal.apiKey"
type="password"
placeholder="访问密钥"
class="h-8 rounded-[4px] border border-[#383838] bg-[#0f0f0f] px-2 text-sm outline-none focus:border-white"
:disabled="fetchModal.loading || fetchModal.adding"
/>
<div class="flex items-center justify-between">
<Button variant="default" :disabled="fetchModal.loading || fetchModal.adding || !fetchModal.baseURL || !fetchModal.apiKey" @click="handleFetchModels">
{{ fetchModal.loading ? "拉取中..." : "拉取列表" }}
</Button>
<span v-if="fetchModal.models.length > 0" class="text-xs text-[#a8a8a8]">
已选 {{ fetchSelectedCount }}/{{ fetchModal.models.length }}
<button type="button" class="ml-2 text-white underline" @click="toggleFetchAll">全选/反选</button>
</span>
</div>
<div v-if="fetchModal.error" class="text-xs text-red-400">{{ fetchModal.error }}</div>
</div>
<div class="min-h-[120px] flex-1 overflow-y-auto rounded-[4px] border border-[#2a2a2a] bg-[#0f0f0f] p-2">
<div v-if="fetchModal.models.length === 0" class="flex h-full items-center justify-center text-sm text-[#666]">
{{ fetchModal.loading ? "正在拉取模型列表..." : "点击「拉取列表」获取可用模型" }}
</div>
<label
v-for="model in fetchModal.models"
:key="model.id"
class="flex cursor-pointer items-center gap-2 rounded-[4px] px-2 py-1.5 text-sm hover:bg-white/5"
>
<input
type="checkbox"
:checked="fetchModal.selected.has(model.id)"
class="accent-white"
@change="toggleFetchModel(model.id)"
/>
<span class="truncate">{{ model.id }}</span>
</label>
</div>
<div class="flex justify-end gap-2 pt-3">
<Button variant="text" :disabled="fetchModal.loading || fetchModal.adding" @click="closeFetchModal">取消</Button>
<Button variant="primary" :disabled="fetchModal.loading || fetchModal.adding || fetchSelectedCount === 0" @click="handleBatchAddModels">
{{ fetchModal.adding ? "添加中..." : `添加已选 ${fetchSelectedCount} 个` }}
</Button>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,548 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Input from "@/components/ui/Input.vue";
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
import Select from "@/components/ui/Select.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { getModelEditorContext } from "@/services/clientApi";
import {
ANTHROPIC_THINKING_EFFORT_DEFAULT,
appState,
buildModelAdapterTestRequestHash,
createEmptyModelAdapter,
CUSTOM_HEADERS_DEFAULT_JSON,
EXTRA_PARAMS_DEFAULT_JSON,
getModelAdapterTestResult,
getModelAdapterTestResultByID,
isModelAdapterTestResultStale,
normalizeModelAdapter,
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest,
saveModelAdapterAt,
toUserError,
validateModelAdapters,
} from "@/state/appState";
import { Window } from "@wailsio/runtime";
import { computed, onMounted, reactive, ref, watch } from "vue";
const modelTypeTabs = [
{ 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]" },
];
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: "Max", 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]" },
];
const editorIndex = ref(-1);
const draft = reactive(createEmptyModelAdapter());
const errorMessage = ref("");
const loading = ref(true);
const lastTestAdapterID = ref("");
const localTestFailure = ref("");
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");
const interfacePlaceholder = computed(() =>
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
);
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
const rememberedModelTestResult = computed(() =>
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
);
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
const modelTestResultStale = computed(() =>
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
);
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
const modelTestSummary = computed(() => {
if (localTestFailure.value) {
return localTestFailure.value;
}
return activeModelTestResult.value?.summaryText || "尚未测试";
});
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
function ensureOpenAIExtraParamsJSON() {
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureCustomHeadersJSON() {
if (!String(draft.customHeadersJSON || "").trim()) {
draft.customHeadersJSON = CUSTOM_HEADERS_DEFAULT_JSON;
}
}
function ensureAnthropicExtraParamsJSON() {
if (!String(draft.anthropicExtraParamsJSON || "").trim()) {
draft.anthropicExtraParamsJSON = EXTRA_PARAMS_DEFAULT_JSON;
}
}
function ensureAnthropicThinkingEffort() {
if (!String(draft.anthropicThinkingEffort || "").trim()) {
draft.anthropicThinkingEffort = ANTHROPIC_THINKING_EFFORT_DEFAULT;
}
}
const fieldTips = {
displayName: "仅用于界面展示,便于你区分不同模型。",
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
apiKey: "调用该模型服务需要使用的访问密钥。",
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
openAIEndpoint: "OpenAI 兼容接口使用的协议端点。未选择时默认使用 /v1/responses。",
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
anthropicMaxTokens: "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
anthropicThinkingEffort: "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
tooltipData: "模型列表 hover 时显示的备注说明。",
};
async function loadContext() {
try {
const ctx = await getModelEditorContext();
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
const parsed = JSON.parse(ctx.adapterJSON || "{}");
Object.assign(draft, normalizeModelAdapter(parsed));
if (!draft.type) {
draft.type = "openai";
}
} catch (_error) {
Object.assign(draft, createEmptyModelAdapter());
draft.type = "openai";
} finally {
loading.value = false;
}
}
async function persistDraft() {
const adapter = normalizeModelAdapter(draft);
const singleCheck = validateModelAdapters([adapter]);
if (singleCheck) {
errorMessage.value = singleCheck;
return { ok: false, error: singleCheck, adapter: null };
}
const result = await saveModelAdapterAt(editorIndex.value, adapter);
if (!result.ok) {
errorMessage.value = result.error;
return { ok: false, error: result.error, adapter: null };
}
if (typeof result.index === "number") {
editorIndex.value = result.index;
}
if (result.adapter) {
Object.assign(draft, normalizeModelAdapter(result.adapter));
}
errorMessage.value = "";
return {
ok: true,
error: "",
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
};
}
async function handleSave() {
const result = await persistDraft();
if (!result.ok) {
return;
}
await Window.Close();
}
async function handleCancel() {
await Window.Close();
}
function handleModelTypeChange(type) {
draft.type = type;
if (type === "openai" && !draft.openAIEndpoint) {
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
} else if (type === "anthropic") {
ensureAnthropicThinkingEffort();
}
}
async function handleTest() {
localTestFailure.value = "";
try {
const saved = await persistDraft();
if (!saved.ok || !saved.adapter) {
return;
}
const result = await runModelAdapterTest(saved.adapter);
if (result?.adapterID) {
lastTestAdapterID.value = result.adapterID;
}
} catch (error) {
const latest = getModelAdapterTestResult(draft);
if (latest?.adapterID) {
lastTestAdapterID.value = latest.adapterID;
return;
}
localTestFailure.value = toUserError(error);
}
}
watch(
directModelTestResult,
(result) => {
if (!result?.adapterID) {
return;
}
lastTestAdapterID.value = result.adapterID;
if (result.status !== "running") {
localTestFailure.value = "";
}
},
{ immediate: true },
);
watch(currentRequestHash, () => {
localTestFailure.value = "";
});
watch(
() => draft.openAIExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureOpenAIExtraParamsJSON();
}
},
);
watch(
() => draft.customHeadersEnabled,
(enabled) => {
if (enabled) {
ensureCustomHeadersJSON();
}
},
);
watch(
() => draft.anthropicExtraParamsEnabled,
(enabled) => {
if (enabled) {
ensureAnthropicExtraParamsJSON();
}
},
);
onMounted(async () => {
await loadContext();
});
</script>
<template>
<div class="flex h-full flex-col text-white">
<div class="flex shrink-0 items-center justify-between px-4 pt-2 pb-2">
<h2 class="text-base font-medium text-white">{{ title }}</h2>
<div class="flex items-center gap-2">
<Button variant="default" @click="handleCancel">取消</Button>
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
</Button>
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
{{ appState.configSaving ? "保存中..." : "保存" }}
</Button>
</div>
</div>
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-[#a8a8a8]">
加载中...
</div>
<div v-else class="flex-1 overflow-y-auto min-h-0 px-4 pb-4">
<div class="flex flex-col gap-4">
<div class="center-row gap-2">
<button
v-for="tab in modelTypeTabs"
:key="tab.value"
type="button"
class="center-row gap-2 rounded-[8px] border px-3 py-2 text-sm transition-colors duration-150"
:class="draft.type === tab.value
? 'border-white bg-white/10 text-white'
: 'border-[#2a2a2a] bg-[#141414] text-[#a8a8a8] hover:border-[#666] hover:text-white'"
@click="handleModelTypeChange(tab.value)"
>
<span :class="[tab.icon, 'text-[16px]']"></span>
<span>{{ tab.label }}</span>
</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.displayName" />
<span>显示名称</span>
</span>
<input
v-model="draft.displayName"
type="text"
placeholder="例如:OpenAI - GPT-4.1"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.modelID" />
<span>模型标识</span>
</span>
<input
v-model="draft.modelID"
type="text"
placeholder="例如:gpt-4.1"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.apiKey" />
<span>访问密钥</span>
</span>
<Input
v-model="draft.apiKey"
type="password"
allow-visibility-toggle
placeholder="例如:sk-xxxxxx"
autocomplete="off"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.baseURL" />
<span>接口地址</span>
</span>
<input
v-model="draft.baseURL"
type="text"
:placeholder="interfacePlaceholder"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.contextWindowTokens" />
<span>上下文窗口</span>
</span>
<input
v-model="contextWindowTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:500000(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label v-if="draft.type === 'openai'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.reasoningEffort" />
<span>推理强度</span>
</span>
<Select
v-model="draft.reasoningEffort"
:options="reasoningEffortOptions"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicMaxTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="anthropicMaxTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:131072(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label v-if="draft.type === 'anthropic'" class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicThinkingEffort" />
<span>思考强度</span>
</span>
<Select
v-model="draft.anthropicThinkingEffort"
:options="anthropicThinkingEffortOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.maxCompletionTokens" />
<span>最大输出 Token</span>
</span>
<input
v-model="maxCompletionTokensInput"
type="text"
inputmode="numeric"
placeholder="例如:278000(留空用默认值)"
class="h-9 rounded-[4px] border border-[#383838] bg-[#141414] px-3 text-sm text-white outline-none focus:border-white"
/>
</label>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.openAIEndpoint" />
<span>接口端点</span>
</span>
<Select
v-model="draft.openAIEndpoint"
:options="openAIEndpointOptions"
/>
</label>
</div>
<div v-if="draft.type === 'openai'" class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.openAIExtraParams" />
<span>额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.openAIExtraParamsEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<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-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<div v-if="draft.type === 'anthropic'" class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.anthropicExtraParams" />
<span>Anthropic 额外参数 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.anthropicExtraParamsEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.anthropicExtraParamsEnabled"
v-model="draft.anthropicExtraParamsJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<div class="rounded-[8px] border border-[#2a2a2a] bg-[#141414] p-3">
<div class="flex items-center justify-between gap-3">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.customHeaders" />
<span>自定义请求头 JSON</span>
</span>
<label class="center-row gap-2 text-xs text-[#a8a8a8]">
<input
v-model="draft.customHeadersEnabled"
type="checkbox"
class="size-4 accent-white"
/>
<span>启用</span>
</label>
</div>
<textarea
v-if="draft.customHeadersEnabled"
v-model="draft.customHeadersJSON"
rows="5"
spellcheck="false"
class="mt-3 min-h-[120px] w-full resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 font-mono text-xs text-white outline-none focus:border-white"
/>
</div>
<label class="flex flex-col gap-1">
<span class="center-row justify-start gap-1.5 text-sm text-[#a8a8a8]">
<Tooltip :content="fieldTips.tooltipData" />
<span>备注</span>
</span>
<textarea
v-model="draft.tooltipData"
rows="3"
placeholder="例如:用于日常代码补全与问答"
class="min-h-[96px] resize-none rounded-[4px] border border-[#383838] bg-[#141414] px-3 py-2 text-sm text-white outline-none focus:border-white"
/>
</label>
<ModelAdapterTestCard
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
:stale="modelTestResultStale"
:show-metrics="true"
/>
<div
v-if="errorMessage"
class="rounded-[8px] border border-[#555]/30 bg-[#141414] px-3 py-2 text-sm text-white"
>
{{ errorMessage }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,249 @@
<script setup>
import Button from "@/components/ui/Button.vue";
import Card from "@/components/ui/Card.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { showModal } from "@/composables/useModal";
import {
deleteProjectRecord,
getProjectSkillStates,
listRecentProjects,
listSkills,
setProjectSkill,
} from "@/services/clientApi";
import { toUserError } from "@/state/appState";
import { onMounted, reactive } from "vue";
const state = reactive({
loading: false,
error: "",
skills: [],
projects: [],
// key: `${projectPath}|${skillName}` → boolean
switches: {},
busy: {},
});
function switchKey(projectPath, skillName) {
return `${projectPath}|${skillName}`;
}
// 命令说明默认折叠,按 skill 名记录展开状态。
const expanded = reactive({});
function toggleExpanded(skillName) {
expanded[skillName] = !expanded[skillName];
}
function formatTime(ms) {
if (!ms) {
return "未知时间";
}
return new Date(ms).toLocaleString();
}
async function loadAll() {
state.loading = true;
state.error = "";
try {
const [projects, skills] = await Promise.all([
listRecentProjects(),
listSkills(),
]);
state.projects = Array.isArray(projects) ? projects : [];
state.skills = Array.isArray(skills) ? skills : [];
const switches = {};
for (const project of state.projects) {
let states = {};
try {
states = (await getProjectSkillStates(project.path)) || {};
} catch {
states = {};
}
for (const skill of state.skills) {
switches[switchKey(project.path, skill.name)] = Boolean(states[skill.name]);
}
}
state.switches = switches;
} catch (error) {
state.error = String(toUserError(error)?.message || error || "加载失败");
} finally {
state.loading = false;
}
}
async function handleToggle(projectPath, skillName, enabled) {
const key = switchKey(projectPath, skillName);
if (state.busy[key]) {
return;
}
state.busy[key] = true;
const previous = Boolean(state.switches[key]);
// 乐观更新,失败后回滚。
state.switches[key] = enabled;
try {
await setProjectSkill(projectPath, skillName, enabled);
} catch (error) {
state.switches[key] = previous;
await showModal({
title: enabled ? "导入失败" : "移除失败",
content: String(toUserError(error)?.message || error || "操作失败"),
showCancel: false,
});
} finally {
state.busy[key] = false;
}
}
async function handleDeleteProject(project) {
const confirmed = await showModal({
title: "删除项目记录",
content:
`将从 Cursor 中删除项目 ${project.path} 的记录,包括:最近打开列表条目、workspaceStorage 目录。\n` +
"注意:该目录内含此项目的全部 AI 聊天记录和扩展工作区状态,删除后不可恢复;项目文件本身不受影响。确定删除?",
});
if (!confirmed) {
return;
}
state.busy[project.path] = true;
try {
await deleteProjectRecord(project.path);
await loadAll();
} catch (error) {
await showModal({
title: "删除失败",
content: String(toUserError(error)?.message || error || "操作失败"),
showCancel: false,
});
} finally {
state.busy[project.path] = false;
}
}
onMounted(() => {
void loadAll();
});
</script>
<template>
<div class="flex flex-col gap-[16px] p-[20px] text-white overflow-y-auto h-full">
<Card>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-[14px] font-medium text-white">内置 Skill</h2>
<div class="text-[12px] text-[#a8a8a8]">
通过开关将 Skill 导入到项目(.cursor/skills/),关闭即移除
</div>
</div>
<Button variant="default" :disabled="state.loading" @click="loadAll">刷新</Button>
</div>
</Card>
<Card v-for="skill in state.skills" :key="skill.name" class="mb-[16px]">
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-[14px] font-medium text-white">{{ skill.name }}</h2>
<div class="text-[12px] text-[#a8a8a8]">{{ skill.description }}</div>
</div>
</div>
<div v-if="skill.commands && skill.commands.length > 0" class="rounded-[4px] bg-[#141414]">
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-[11px] text-[#666] hover:text-white"
@click="toggleExpanded(skill.name)"
>
<span>斜杠命令({{ skill.commands.length }} 个,导入后在目标项目的 / 命令列表中出现)</span>
<span class="icon-[mdi--chevron-down] text-[14px] transition-transform" :class="expanded[skill.name] ? 'rotate-180' : ''" />
</button>
<div v-if="expanded[skill.name]" class="flex flex-col gap-1.5 px-3 pb-3">
<div
v-for="command in skill.commands"
:key="command.name"
class="flex items-start gap-3"
>
<code class="shrink-0 rounded-[3px] bg-[#0f0f0f] px-1.5 py-0.5 text-[11px] text-white">/{{ command.name }}</code>
<span class="text-[11px] leading-[16px] text-[#a8a8a8]">{{ command.description }}</span>
</div>
</div>
</div>
</div>
</Card>
<div v-if="state.error"
class="rounded-[4px] border border-[#ff4444]/30 bg-[#ff4444]/5 px-3 py-2 text-sm text-[#ff4444]">
{{ state.error }}
</div>
<div v-if="!state.loading && state.projects.length === 0"
class="flex min-h-[160px] items-center justify-center rounded-[4px] border border-dashed border-[#2a2a2a] bg-[#141414] px-4 text-sm text-[#a8a8a8]">
未找到打开过的 Cursor 项目
</div>
<Card v-if="state.projects.length > 0">
<div class="overflow-x-auto">
<table class="w-full min-w-[560px] border-collapse">
<thead>
<tr class="border-b border-[#2a2a2a]">
<th class="px-2 py-2 text-left text-[11px] font-medium text-[#666]">项目</th>
<th
v-for="skill in state.skills"
:key="skill.name"
class="whitespace-nowrap px-2 py-2 text-center text-[11px] font-medium text-[#666]"
>
<span class="inline-flex items-center gap-1">
{{ skill.name }}
<Tooltip :content="skill.description || '内置 Skill'" />
</span>
</th>
<th class="w-[36px] px-2 py-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="project in state.projects"
:key="project.path"
class="group border-b border-[#1e1e1e] last:border-b-0 hover:bg-[#1a1a1a]"
>
<td class="max-w-[320px] px-2 py-2">
<div class="truncate text-[12px] text-white" :title="project.path">
{{ project.path }}
</div>
<div class="text-[10px] text-[#666]">{{ formatTime(project.lastOpenedMs) }}</div>
</td>
<td
v-for="skill in state.skills"
:key="skill.name"
class="px-2 py-2 text-center"
>
<div class="inline-flex">
<Switch
enabled-text="已导入"
disabled-text="未导入"
:enabled="Boolean(state.switches[switchKey(project.path, skill.name)])"
:busy="Boolean(state.busy[switchKey(project.path, skill.name)])"
@change="(value) => handleToggle(project.path, skill.name, value)"
/>
</div>
</td>
<td class="px-2 py-2 text-right">
<button
type="button"
class="inline-flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[#666] opacity-0 transition-opacity hover:bg-[#ff4444]/10 hover:text-[#ff4444] focus:opacity-100 group-hover:opacity-100"
:disabled="Boolean(state.busy[project.path])"
title="删除此项目记录"
@click="handleDeleteProject(project)"
>
<span
class="text-[15px]"
:class="state.busy[project.path] ? 'icon-[mdi--loading] animate-spin' : 'icon-[mdi--delete-outline]'"
/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</template>

View File

@@ -0,0 +1,77 @@
const { addDynamicIconSelectors } = require("@iconify/tailwind");
const iconSafelist = [
"icon-[ant-design--bilibili-outlined]",
"icon-[bxl--openai]",
"icon-[cil--badge]",
"icon-[dashicons--yes]",
"icon-[ic--round-close]",
"icon-[ic--round-minus]",
"icon-[logos--claude-icon]",
"icon-[mdi--api]",
"icon-[mdi--brain]",
"icon-[mdi--check]",
"icon-[mdi--chevron-down]",
"icon-[mdi--content-copy]",
"icon-[mdi--eye-off-outline]",
"icon-[mdi--eye-outline]",
"icon-[mdi--file-document-outline]",
"icon-[mdi--head-cog-outline]",
"icon-[mdi--head-lightbulb-outline]",
"icon-[mdi--head-outline]",
"icon-[mdi--information-outline]",
"icon-[mdi--message-text-outline]",
"icon-[mdi--pause]",
"icon-[mdi--play]",
"icon-[mdi--refresh]",
"icon-[mdi--wifi]",
"icon-[mingcute--loading-fill]",
];
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,jsx,ts,tsx}"],
safelist: [...iconSafelist, "z-999", "z-9999", "z-99999"],
theme: {
extend: {
colors: {
primary: {
50: "#f0f7ff",
100: "#e6f4ff",
200: "#bae0ff",
300: "#91caff",
400: "#69b1ff",
500: "#4096ff",
600: "#1677ff",
700: "#0958d9",
800: "#003eb3",
900: "#002c8c",
950: "#001d66",
DEFAULT: "#1677ff",
},
},
fontFamily: {
num: [
"HFKos",
"PingFang-Medium",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"\"Segoe UI\"",
"Roboto",
"sans-serif",
],
},
fontSize: {
xs: ["12px", { lineHeight: "16px" }],
sm: ["13px", { lineHeight: "18px" }],
lg: ["20px", { lineHeight: "28px" }],
},
zIndex: {
999: "999",
9999: "9999",
99999: "99999",
},
},
},
plugins: [addDynamicIconSelectors()],
};

37
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,37 @@
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import wails from "@wailsio/runtime/plugins/vite";
import { codeInspectorPlugin } from "code-inspector-plugin";
import path from "path";
import { defineConfig } from "vite";
import topLevelAwait from "vite-plugin-top-level-await";
import { staticI18nPlugin } from "./plugins/static-i18n-plugin.js";
const isDev = process.env.NODE_ENV === "development";
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@bindings": path.resolve(__dirname, "./bindings"),
},
},
build: {
target: ["es2019", "safari13"],
cssTarget: "safari13",
},
plugins: [
isDev &&
codeInspectorPlugin({
bundler: "vite",
editor: "code",
hotKeys: ["ctrlKey"],
}),
wails("./bindings"),
topLevelAwait(),
staticI18nPlugin(),
vue(),
vueJsx(),
].filter(Boolean),
});

1758
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

75
go.mod Normal file
View File

@@ -0,0 +1,75 @@
module cursor
go 1.25.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.1
connectrpc.com/connect v1.19.1
github.com/denisbrodbeck/machineid v1.0.1
github.com/elazarl/goproxy v1.7.2
github.com/firecrawl/html-to-markdown v0.0.0-20260312013131-1af9901a5d61
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/jhump/protoreflect v1.18.0
github.com/leaanthony/u v1.1.1
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-isatty v0.0.20
github.com/sergi/go-diff v1.4.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
golang.org/x/net v0.49.0
golang.org/x/sys v0.42.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/PuerkitoBio/goquery v1.9.2 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

300
go.sum Normal file
View File

@@ -0,0 +1,300 @@
codeberg.org/readeck/go-readability/v2 v2.1.1 h1:1tEwxFuUqDRP5JABzDHXGWRx5p9S7TElS3U8qQwXC5Y=
codeberg.org/readeck/go-readability/v2 v2.1.1/go.mod h1:x3WG9GpWWnkRb7ajP1NmOKSHbafxNUb736lrDZXeXrs=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/firecrawl/html-to-markdown v0.0.0-20260312013131-1af9901a5d61 h1:UBDJu23HX7g13QTJGMf1PlI1MVPTQk9o/AISxgt2axo=
github.com/firecrawl/html-to-markdown v0.0.0-20260312013131-1af9901a5d61/go.mod h1:jngam+MdNp7FZkhSTlFsuA5hXY21X0+vuiGlpgo2n5o=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

2
internal/app/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package app 负责组装桌面应用入口、窗口、托盘和 Wails 服务。
package app

View File

@@ -0,0 +1,25 @@
// 退出联动确认对话框:Cursor 未退出时询问用户是否同步关闭。
package app
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// showQuitConfirmDialog 显示确认对话框,返回用户是否确认退出并关闭 Cursor。
// 注意:Wails Windows 实现按 MessageBox 返回值(Yes/No)匹配按钮标签,
// 因此按钮标签必须使用 "Yes"/"No" 才能触发回调。
func showQuitConfirmDialog(app *application.App) bool {
confirmed := false
dialog := app.Dialog.Question()
dialog.SetTitle("退出 Local-Cursor")
dialog.SetMessage("检测到 Cursor 正在运行。\n退出 Local-Cursor 将同步关闭所有 Cursor 进程,是否确定?")
yesButton := dialog.AddButton("Yes").OnClick(func() {
confirmed = true
})
yesButton.SetAsDefault()
noButton := dialog.AddButton("No")
noButton.SetAsCancel()
dialog.Show()
// Windows 实现中 MessageBox 为模态调用,Show() 返回时回调已执行。
return confirmed
}

271
internal/app/runner.go Normal file
View File

@@ -0,0 +1,271 @@
package app
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"io/fs"
goruntime "runtime"
"strings"
"time"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/buildinfo"
"github.com/leaanthony/u"
bridge "cursor/internal/bridge"
"cursor/internal/certs"
"cursor/internal/cursor"
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
const (
appName = "Local-Cursor"
)
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
type EmbeddedResources struct {
// Assets 表示当前声明中的 Assets。
Assets fs.FS
// AppIcon 表示当前声明中的 AppIcon。
AppIcon []byte
// TrayIcon 表示当前声明中的 TrayIcon。
TrayIcon []byte
}
// init 用于处理与 init 相关的逻辑。
func init() {
application.RegisterEvent[bridge.ProxyState]("proxy:state")
application.RegisterEvent[bridge.UserConfig]("user-config:changed")
application.RegisterEvent[bridge.ModelAdapterTestResultsPayload]("model-adapter-test:updated")
}
// Run 用于处理与 Run 相关的逻辑。
func Run(resources EmbeddedResources) error {
logger.Init()
ensureStartMenuShortcut()
netproxy.InstallDefaultTransport()
embeddedCACertPEM := certs.EmbeddedCACertPEM()
logEmbeddedCAInfo(embeddedCACertPEM)
certManager, err := certs.NewEmbeddedManager()
if err != nil {
return err
}
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
if err != nil {
return err
}
proxyService := bridge.NewProxyService(proxyServer, certManager, embeddedCACertPEM)
metricsService := bridge.NewMetricsService()
windowService := bridge.NewWindowService()
skillService := bridge.NewSkillService()
app := application.New(application.Options{
Name: appName,
Description: appName,
Services: []application.Service{
application.NewService(proxyService),
application.NewService(metricsService),
application.NewService(windowService),
application.NewService(skillService),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(resources.Assets),
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
OnShutdown: func() {
proxyService.ShutdownForQuit()
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.local-curosr.single-instance",
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
logger.Infof("检测到实例请求,已忽略")
// 不激活窗口,避免干扰用户工作
},
},
})
windowService.SetApp(app)
mainWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: appName,
Width: 760,
Height: 660,
MinWidth: 680,
MinHeight: 560,
DisableResize: false,
Frameless: goruntime.GOOS == "windows",
URL: "/",
Hidden: false,
HideOnEscape: false,
MinimiseButtonState: application.ButtonEnabled,
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255},
Mac: application.MacWindow{
Backdrop: application.MacBackdropLiquidGlass,
DisableShadow: false,
TitleBar: application.MacTitleBar{
AppearsTransparent: true,
Hide: false,
HideTitle: true,
FullSizeContent: true,
UseToolbar: false,
HideToolbarSeparator: true,
},
WebviewPreferences: application.MacWebviewPreferences{
FullscreenEnabled: u.True,
TextInteractionEnabled: u.True,
AllowsBackForwardNavigationGestures: u.False,
},
},
Windows: application.WindowsWindow{
HiddenOnTaskbar: false,
},
})
window := mainWindow
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.Hide()
e.Cancel()
})
showMainWindow := func() {
window.Show().Focus()
}
toggleMainWindow := func() {
if window.IsVisible() {
window.Hide()
return
}
showMainWindow()
}
systray := app.SystemTray.New()
menu := app.Menu.New()
statusItem := menu.Add("状态:未启动").SetEnabled(false)
menu.AddSeparator()
startItem := menu.Add("启动服务")
stopItem := menu.Add("停止服务")
menu.AddSeparator()
menu.Add("显示窗口").OnClick(func(ctx *application.Context) {
showMainWindow()
})
menu.Add("隐藏窗口").OnClick(func(ctx *application.Context) {
window.Hide()
})
menu.AddSeparator()
menu.Add("退出").OnClick(func(ctx *application.Context) {
// 与 Cursor 进程联动:Cursor 未退出时先询问,确认后同步关闭 Cursor。
if cursor.IsCursorRunning() {
confirmed := showQuitConfirmDialog(app)
if !confirmed {
return
}
if err := cursor.KillCursorProcesses(); err != nil {
logger.Errorf("quit: kill cursor processes failed err=%v", err)
}
}
proxyService.ShutdownForQuit()
app.Quit()
})
refreshTray := func() {
state := proxyService.GetState()
if state.Running {
statusItem.SetLabel("状态:运行中")
startItem.SetEnabled(false)
stopItem.SetEnabled(true)
} else {
statusItem.SetLabel("状态:未启动")
startItem.SetEnabled(true)
stopItem.SetEnabled(false)
}
}
app.Event.On("proxy:state", func(event *application.CustomEvent) {
refreshTray()
})
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
logger.Infof("应用版本:v%s", buildinfo.CurrentVersion())
go func() {
logger.Infof("application started, begin auto start service in background")
if _, err := proxyService.StartProxy(); err != nil {
logger.Errorf("自动启动服务失败: %v", err)
} else {
state := proxyService.GetState()
logger.Infof("代理已自动启动: %s", state.ProxyListenAddr)
}
}()
})
startItem.OnClick(func(ctx *application.Context) {
if _, err := proxyService.StartProxy(); err != nil {
logger.Errorf("启动服务失败: %v", err)
}
refreshTray()
})
stopItem.OnClick(func(ctx *application.Context) {
if _, err := proxyService.StopProxy(); err != nil {
logger.Errorf("停止服务失败: %v", err)
}
refreshTray()
})
if len(resources.AppIcon) > 0 {
switch goruntime.GOOS {
case "darwin":
systray.SetTemplateIcon(resources.TrayIcon)
case "windows":
systray.SetIcon(resources.AppIcon)
default:
systray.SetIcon(resources.TrayIcon)
}
}
systray.SetTooltip(appName)
systray.OnClick(toggleMainWindow).SetMenu(menu)
refreshTray()
return app.Run()
}
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
func logEmbeddedCAInfo(certPEM []byte) {
if len(certPEM) == 0 {
logger.Errorf("embedded CA is empty")
return
}
cert, err := parseEmbeddedCert(certPEM)
if err != nil {
logger.Errorf("parse embedded CA failed: %v", err)
return
}
sum := sha256.Sum256(cert.Raw)
logger.Infof(
"embedded CA loaded: sha256=%s subject=%s valid=%s~%s",
strings.ToUpper(hex.EncodeToString(sum[:])),
cert.Subject.String(),
cert.NotBefore.Format(time.RFC3339),
cert.NotAfter.Format(time.RFC3339),
)
}
// parseEmbeddedCert 用于处理与 parseEmbeddedCert 相关的逻辑。
func parseEmbeddedCert(data []byte) (*x509.Certificate, error) {
if block, _ := pem.Decode(data); block != nil {
return x509.ParseCertificate(block.Bytes)
}
return x509.ParseCertificate(data)
}

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