commit db73cf5a39bbef0a0edc25a3164c0188fcf9b95c Author: FuDie0915 Date: Tue Nov 10 01:24:28 2026 +0800 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce9d47a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!go.mod +!go.sum +!cursor-tab-server/ +!cursor-tab-server/** +!gen/ +!gen/** +!internal/ +!internal/** diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caddafa --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94043db --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..754eb21 --- /dev/null +++ b/README.md @@ -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 # 使用量聚合统计 +│ └── / # 会话状态 +├── 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 汉化包 \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..434848c --- /dev/null +++ b/Taskfile.yml @@ -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 diff --git a/build/Taskfile.yml b/build/Taskfile.yml new file mode 100644 index 0000000..e88106d --- /dev/null +++ b/build/Taskfile.yml @@ -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 diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..457f9a9 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/config.yml b/build/config.yml new file mode 100644 index 0000000..88f5c83 --- /dev/null +++ b/build/config.yml @@ -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: 其他数据 diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 0000000..5c9ea64 --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,37 @@ + + + + + CFBundleExecutable + Local-Cursor + CFBundleGetInfoString + Local-Cursor + CFBundleIconFile + icons + CFBundleIconName + appicon + CFBundleIdentifier + com.Local-Cursor + CFBundleName + Local-Cursor + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.1.8 + CFBundleVersion + 1.1.8 + LSMinimumSystemVersion + 10.15.0 + LSUIElement + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHighResolutionCapable + true + NSHumanReadableCopyright + © 2026, Local-Cursor + + \ No newline at end of file diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 0000000..b104ecd --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleExecutable + Local-Cursor + CFBundleGetInfoString + Local-Cursor + CFBundleIconFile + icons + CFBundleIconName + appicon + CFBundleIdentifier + com.Local-Cursor + CFBundleName + Local-Cursor + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.1.8 + CFBundleVersion + 1.1.8 + LSMinimumSystemVersion + 10.15.0 + LSUIElement + + NSHighResolutionCapable + true + NSHumanReadableCopyright + © 2026, Local-Cursor + + \ No newline at end of file diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml new file mode 100644 index 0000000..ace1887 --- /dev/null +++ b/build/darwin/Taskfile.yml @@ -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}}' diff --git a/build/darwin/icons.icns b/build/darwin/icons.icns new file mode 100644 index 0000000..ffa68cc Binary files /dev/null and b/build/darwin/icons.icns differ diff --git a/build/dmg-extras/提示损坏?点我.command b/build/dmg-extras/提示损坏?点我.command new file mode 100644 index 0000000..9c66b95 --- /dev/null +++ b/build/dmg-extras/提示损坏?点我.command @@ -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 "按回车退出..." diff --git a/build/docker/Dockerfile.cross b/build/docker/Dockerfile.cross new file mode 100644 index 0000000..fbe649c --- /dev/null +++ b/build/docker/Dockerfile.cross @@ -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"] diff --git a/build/docker/build-linux.sh b/build/docker/build-linux.sh new file mode 100644 index 0000000..a66e366 --- /dev/null +++ b/build/docker/build-linux.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +log() { + printf '[linux-build] %s\n' "$*" +} + +export GOOS=linux +export GOARCH=amd64 +export CGO_ENABLED=1 +export CGO_CFLAGS="-w" + +APP=${APP_NAME:-$(basename "$(pwd)")} +BIN_PATH="bin/${APP}-linux-amd64" +LOG_MODE=${GO_BUILD_LOG_MODE:-quiet} + +case "$LOG_MODE" in + quiet|heartbeat) + ;; + verbose|trace) + log "workspace: $(pwd)" + log "target: ${APP} (${GOOS}/${GOARCH})" + log "go version: $(go version)" + log "log mode: ${LOG_MODE}" + ;; + *) + log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet" + LOG_MODE="quiet" + ;; +esac + +if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then + log "frontend/dist missing, building frontend assets in container" + ( + cd frontend + yarn install --frozen-lockfile + yarn run build + ) +fi + +mkdir -p bin + +LDFLAGS="-s -w" +if [ -n "${EXTRA_LDFLAGS:-}" ]; then + LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS" +fi + +TAGS="production" +if [ -n "${EXTRA_TAGS:-}" ]; then + TAGS="${TAGS},${EXTRA_TAGS}" +fi + +set -- go build +case "$LOG_MODE" in + quiet) + ;; + heartbeat) + ;; + verbose) + set -- "$@" -v + ;; + trace) + set -- "$@" -x -v + ;; +esac + +set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" . + +if "$@"; then + : +else + status="$?" + log "go build failed with exit code ${status}" + exit "$status" +fi + +case "$LOG_MODE" in + verbose|trace) + log "built: ${BIN_PATH}" + ;; +esac diff --git a/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js new file mode 100644 index 0000000..1ea1058 --- /dev/null +++ b/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js @@ -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); diff --git a/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 0000000..3dd1807 --- /dev/null +++ b/build/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,2 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml new file mode 100644 index 0000000..ddcfb97 --- /dev/null +++ b/build/linux/Taskfile.yml @@ -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}}' diff --git a/build/linux/desktop b/build/linux/desktop new file mode 100644 index 0000000..4ffd62a --- /dev/null +++ b/build/linux/desktop @@ -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 + + diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml new file mode 100644 index 0000000..4d91377 --- /dev/null +++ b/build/linux/nfpm/nfpm.yaml @@ -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" diff --git a/build/tray.png b/build/tray.png new file mode 100644 index 0000000..1bcb06a Binary files /dev/null and b/build/tray.png differ diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml new file mode 100644 index 0000000..ba3edad --- /dev/null +++ b/build/windows/Taskfile.yml @@ -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}}' diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..90dc704 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..80849af --- /dev/null +++ b/build/windows/info.json @@ -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" + } + } +} \ No newline at end of file diff --git a/build/windows/msix/app_manifest.xml b/build/windows/msix/app_manifest.xml new file mode 100644 index 0000000..f2584d8 --- /dev/null +++ b/build/windows/msix/app_manifest.xml @@ -0,0 +1,55 @@ + + + + + + + Cursor助手 + Cursor助手 + Cursor助手 + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/msix/template.xml b/build/windows/msix/template.xml new file mode 100644 index 0000000..79f7141 --- /dev/null +++ b/build/windows/msix/template.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + false + Cursor助手 + Cursor助手 + Cursor助手 + Assets\AppIcon.png + + + + + + + diff --git a/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe b/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe new file mode 100644 index 0000000..89a56ec Binary files /dev/null and b/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe differ diff --git a/build/windows/nsis/project.nsi b/build/windows/nsis/project.nsi new file mode 100644 index 0000000..dc07fb3 --- /dev/null +++ b/build/windows/nsis/project.nsi @@ -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 diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh new file mode 100644 index 0000000..c116d08 --- /dev/null +++ b/build/windows/nsis/wails_tools.nsh @@ -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 \ No newline at end of file diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..1a5561e --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + \ No newline at end of file diff --git a/cmd/ext-tool/extractor.go b/cmd/ext-tool/extractor.go new file mode 100644 index 0000000..5375028 --- /dev/null +++ b/cmd/ext-tool/extractor.go @@ -0,0 +1,1805 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/jhump/protoreflect/desc" + "github.com/jhump/protoreflect/desc/protoparse" +) + +// isGooglePkg checks if a package is a Google standard package that should not be generated +func isGooglePkg(pkg string) bool { + return pkg == "google.protobuf" || pkg == "google.rpc" +} + +// Scalar type mapping +var scalarTypes = map[int]string{ + 1: "double", + 2: "float", + 3: "int64", + 4: "uint64", + 5: "int32", + 6: "fixed64", + 7: "fixed32", + 8: "bool", + 9: "string", + 12: "bytes", + 13: "uint32", + 15: "sfixed32", + 16: "sfixed64", + 17: "sint32", + 18: "sint64", +} + +var strictExtractionValidation = true + +type extractionDiagnostics struct { + totalFieldObjects int + parsedFieldObjects int + skippedFieldObjects int + skippedFieldSamples []string + unresolvedTypeRefs map[string]int + emptyMessages []string + placeholderHits []string +} + +func newExtractionDiagnostics() *extractionDiagnostics { + return &extractionDiagnostics{ + unresolvedTypeRefs: make(map[string]int), + } +} + +func (d *extractionDiagnostics) addSkippedField(fieldObject string, reason error) { + if d == nil { + return + } + d.totalFieldObjects++ + d.skippedFieldObjects++ + if len(d.skippedFieldSamples) < 20 { + trimmed := strings.TrimSpace(fieldObject) + if len(trimmed) > 140 { + trimmed = trimmed[:140] + "..." + } + if reason != nil { + d.skippedFieldSamples = append(d.skippedFieldSamples, fmt.Sprintf("%s | %s", reason.Error(), trimmed)) + } else { + d.skippedFieldSamples = append(d.skippedFieldSamples, trimmed) + } + } +} + +func (d *extractionDiagnostics) addParsedField() { + if d == nil { + return + } + d.totalFieldObjects++ + d.parsedFieldObjects++ +} + +func (d *extractionDiagnostics) addUnresolvedType(ref string) { + if d == nil { + return + } + key := strings.TrimSpace(ref) + if key == "" { + key = "" + } + d.unresolvedTypeRefs[key]++ +} + +func SetStrictMode(enabled bool) { + strictExtractionValidation = enabled +} + +var activeDiagnostics *extractionDiagnostics + +var ( + noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`) + nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`) + kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`) + enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`) + tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`) + oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`) + repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`) + optRe = regexp.MustCompile(`opt:\s*(!0|true)`) + keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`) + mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`) + mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`) + mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`) + oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`) + varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`) + streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`) + shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`) +) + +type Field struct { + No int `json:"no"` + Name string `json:"name"` + Kind string `json:"kind"` + T any `json:"T"` // int for scalar, string for message ref + Oneof string `json:"oneof"` // oneof group name + Repeated bool `json:"repeated"` + Opt bool `json:"opt"` // optional + MapKey int `json:"K"` // map key type (scalar type number) + MapValueKind string // "scalar" or "message" + MapValueT any // scalar type number or message var name +} + +type Message struct { + TypeName string + VarName string // JS external variable name (e.g., tPe) + InternalName string // JS internal class name (e.g., bd) + Fields []Field + Package string + ShortName string + Pos int + ModuleStart int +} + +type Enum struct { + TypeName string + VarName string + Values []EnumValue + Package string + ShortName string + Pos int + ModuleStart int +} + +type EnumValue struct { + No int + Name string +} + +type Service struct { + TypeName string + VarName string + Methods []Method + Package string + ShortName string + Pos int + ModuleStart int +} + +type Method struct { + Name string + InputType string // variable name + OutputType string // variable name + Kind string // Unary, ServerStreaming, ClientStreaming, BiDiStreaming +} + +type symbolDef struct { + TypeName string + Pos int + Kind string + ModuleStart int +} + +type TypeResolver struct { + bySymbol map[string][]symbolDef + byShort map[string][]symbolDef +} + +func newTypeResolver(messages []Message, enums []Enum, aliases map[string][]string) *TypeResolver { + resolver := &TypeResolver{ + bySymbol: make(map[string][]symbolDef), + byShort: make(map[string][]symbolDef), + } + + add := func(symbol, typeName string, pos int, moduleStart int, kind string) { + symbol = strings.TrimSpace(symbol) + typeName = strings.TrimSpace(typeName) + if symbol == "" || typeName == "" { + return + } + def := symbolDef{TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind} + resolver.bySymbol[symbol] = append(resolver.bySymbol[symbol], def) + _, shortName := parseTypeName(typeName) + if shortName != "" { + resolver.byShort[shortName] = append(resolver.byShort[shortName], def) + underscoreAlias := strings.ReplaceAll(shortName, ".", "_") + if underscoreAlias != shortName { + resolver.byShort[underscoreAlias] = append(resolver.byShort[underscoreAlias], def) + } + if idx := strings.LastIndex(shortName, "."); idx > 0 && idx+1 < len(shortName) { + resolver.byShort[shortName[idx+1:]] = append(resolver.byShort[shortName[idx+1:]], def) + } + if idx := strings.LastIndex(underscoreAlias, "_"); idx > 0 && idx+1 < len(underscoreAlias) { + resolver.byShort[underscoreAlias[idx+1:]] = append(resolver.byShort[underscoreAlias[idx+1:]], def) + } + } + } + + for _, msg := range messages { + add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message") + if msg.InternalName != "" && msg.InternalName != msg.VarName { + add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message") + } + for _, alias := range aliasesForSymbols(aliases, msg.VarName, msg.InternalName) { + add(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message") + } + } + for _, enum := range enums { + add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum") + for _, alias := range aliasesForSymbols(aliases, enum.VarName) { + add(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum") + } + } + + return resolver +} + +func buildAliasIndex(text string) map[string][]string { + matches := varAliasRe.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + + direct := make(map[string]string, len(matches)) + for _, match := range matches { + alias := strings.TrimSpace(match[1]) + target := strings.TrimSpace(match[2]) + if alias == "" || target == "" || alias == target { + continue + } + direct[alias] = target + } + + resolveRoot := func(symbol string) string { + seen := make(map[string]bool) + current := symbol + for { + if seen[current] { + return symbol + } + seen[current] = true + next := direct[current] + if next == "" { + return current + } + current = next + } + } + + aliases := make(map[string][]string) + for alias := range direct { + root := resolveRoot(alias) + if root == alias { + continue + } + aliases[root] = append(aliases[root], alias) + } + for root := range aliases { + sort.Strings(aliases[root]) + } + return aliases +} + +func aliasesForSymbols(aliases map[string][]string, symbols ...string) []string { + if len(aliases) == 0 { + return nil + } + seen := make(map[string]bool) + var result []string + for _, symbol := range symbols { + for _, alias := range aliases[strings.TrimSpace(symbol)] { + if alias == "" || seen[alias] { + continue + } + seen[alias] = true + result = append(result, alias) + } + } + sort.Strings(result) + return result +} + +func looksLikeFullTypeName(ref string) bool { + trimmed := strings.TrimSpace(ref) + if strings.HasPrefix(trimmed, "google.protobuf.") || strings.HasPrefix(trimmed, "google.rpc.") { + return true + } + matched, _ := regexp.MatchString(`^[\w.]+\.v\d+\.[\w.]+$`, trimmed) + return matched +} + +func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (symbolDef, bool) { + if len(candidates) == 0 { + return symbolDef{}, false + } + + filtered := candidates + if strings.TrimSpace(preferredPkg) != "" { + tmp := make([]symbolDef, 0, len(candidates)) + for _, item := range candidates { + pkg, _ := parseTypeName(item.TypeName) + if pkg == preferredPkg { + tmp = append(tmp, item) + } + } + if len(tmp) > 0 { + filtered = tmp + } + } + + if strings.TrimSpace(expectedKind) != "" { + tmp := make([]symbolDef, 0, len(filtered)) + for _, item := range filtered { + if item.Kind == expectedKind { + tmp = append(tmp, item) + } + } + if len(tmp) > 0 { + filtered = tmp + } + } + + if contextModuleStart > 0 { + tmp := make([]symbolDef, 0, len(filtered)) + for _, item := range filtered { + if item.ModuleStart == contextModuleStart { + tmp = append(tmp, item) + } + } + if len(tmp) > 0 { + filtered = tmp + } + } + + // Pick absolute nearest definition, prefer previous if distance ties. + bestIndex := -1 + bestDistance := 0 + bestIsFuture := false + for index, item := range filtered { + distance := absInt(item.Pos - contextPos) + isFuture := item.Pos > contextPos + if bestIndex == -1 { + bestIndex = index + bestDistance = distance + bestIsFuture = isFuture + continue + } + if distance < bestDistance { + bestIndex = index + bestDistance = distance + bestIsFuture = isFuture + continue + } + if distance == bestDistance { + // Same distance: prefer previous definition over future. + if bestIsFuture && !isFuture { + bestIndex = index + bestIsFuture = isFuture + } + } + } + if bestIndex < 0 { + return symbolDef{}, false + } + return filtered[bestIndex], true +} + +func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (string, bool) { + if resolver == nil { + return "", false + } + + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + return "", false + } + if looksLikeFullTypeName(trimmed) { + return trimmed, true + } + + resolveBySymbol := func(symbol string, preferSameModule bool) (string, bool) { + candidates := resolver.bySymbol[symbol] + if len(candidates) == 0 { + return "", false + } + moduleStart := 0 + if preferSameModule { + moduleStart = contextModuleStart + } + best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind) + if !ok { + return "", false + } + return best.TypeName, true + } + resolveByShort := func(symbol string, preferSameModule bool) (string, bool) { + candidates := resolver.byShort[symbol] + if len(candidates) == 0 { + return "", false + } + moduleStart := 0 + if preferSameModule { + moduleStart = contextModuleStart + } + best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind) + if !ok { + return "", false + } + return best.TypeName, true + } + + if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok { + return typeName, true + } + if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok { + return typeName, true + } + + if strings.Contains(trimmed, ".") { + parts := strings.Split(trimmed, ".") + last := parts[len(parts)-1] + if typeName, ok := resolveByShort(last, false); ok { + return typeName, true + } + first := parts[0] + if typeName, ok := resolveBySymbol(first, false); ok { + return typeName, true + } + } + + return "", false +} + +func fallbackTypeToken(ref string) string { + token := strings.TrimSpace(ref) + if token == "" { + return token + } + if strings.Contains(token, ".") { + parts := strings.Split(token, ".") + return parts[len(parts)-1] + } + return token +} + +func absInt(value int) int { + if value < 0 { + return -value + } + return value +} + +var moduleStartRe = regexp.MustCompile(`(?:^|,)(\d+):(?:function\([\w$,]*\)|\([\w$,]*\)=>)\{`) + +func buildModuleStarts(text string) []int { + matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1) + starts := make([]int, 0, len(matches)) + for _, match := range matches { + starts = append(starts, match[0]) + } + return starts +} + +func moduleStartForPos(moduleStarts []int, pos int) int { + if len(moduleStarts) == 0 { + return 0 + } + index := sort.Search(len(moduleStarts), func(i int) bool { + return moduleStarts[i] > pos + }) - 1 + if index < 0 { + return 0 + } + return moduleStarts[index] +} + +// ExtractProtos extracts proto definitions from formatted JS file +func ExtractProtos(inputFile, outputDir string) { + activeDiagnostics = newExtractionDiagnostics() + defer func() { + activeDiagnostics = nil + }() + + content, err := os.ReadFile(inputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + + text := string(content) + moduleStarts := buildModuleStarts(text) + aliases := buildAliasIndex(text) + + // Extract messages, enums, and services + messages := extractMessages(text, moduleStarts) + enums := extractEnums(text, moduleStarts) + services := extractServices(text, moduleStarts) + for _, msg := range messages { + if len(msg.Fields) == 0 { + activeDiagnostics.emptyMessages = append(activeDiagnostics.emptyMessages, msg.TypeName) + } + } + + resolver := newTypeResolver(messages, enums, aliases) + + // Generate proto files + generateProtos(messages, enums, services, resolver, outputDir) + + validateErr := validateGeneratedProtos(outputDir, activeDiagnostics) + + printDiagnosticsSummary(activeDiagnostics) + + if strictExtractionValidation && hasValidationFailure(activeDiagnostics, validateErr) { + if validateErr != nil { + fmt.Fprintf(os.Stderr, "Validation failed: %v\n", validateErr) + } + os.Exit(1) + } + + if validateErr != nil { + fmt.Fprintf(os.Stderr, "Validation warning: %v\n", validateErr) + } + + fmt.Printf("提取完成: %d 个消息, %d 个枚举, %d 个服务\n", len(messages), len(enums), len(services)) +} + +func hasValidationFailure(diag *extractionDiagnostics, validateErr error) bool { + if validateErr != nil { + return true + } + if diag == nil { + return false + } + if diag.skippedFieldObjects > 0 { + return true + } + if len(diag.unresolvedTypeRefs) > 0 { + return true + } + if len(diag.placeholderHits) > 0 { + return true + } + return false +} + +func printDiagnosticsSummary(diag *extractionDiagnostics) { + if diag == nil { + return + } + + fmt.Printf( + "诊断汇总: fields %d/%d 解析成功, skipped=%d, unresolved=%d, placeholders=%d, empty_messages=%d\n", + diag.parsedFieldObjects, + diag.totalFieldObjects, + diag.skippedFieldObjects, + len(diag.unresolvedTypeRefs), + len(diag.placeholderHits), + len(diag.emptyMessages), + ) + + if diag.skippedFieldObjects > 0 && len(diag.skippedFieldSamples) > 0 { + fmt.Println("字段解析失败样例:") + for _, sample := range diag.skippedFieldSamples { + fmt.Printf(" - %s\n", sample) + } + } + + if len(diag.unresolvedTypeRefs) > 0 { + keys := make([]string, 0, len(diag.unresolvedTypeRefs)) + for key := range diag.unresolvedTypeRefs { + keys = append(keys, key) + } + sort.Strings(keys) + fmt.Println("未解析类型引用:") + for _, key := range keys { + fmt.Printf(" - %s (%d)\n", key, diag.unresolvedTypeRefs[key]) + } + } + + if len(diag.placeholderHits) > 0 { + fmt.Println("占位字段命中:") + for i, hit := range diag.placeholderHits { + if i >= 20 { + fmt.Printf(" - ... and %d more\n", len(diag.placeholderHits)-20) + break + } + fmt.Printf(" - %s\n", hit) + } + } +} + +func validateGeneratedProtos(outputDir string, diag *extractionDiagnostics) error { + entries, err := os.ReadDir(outputDir) + if err != nil { + return fmt.Errorf("read output dir failed: %w", err) + } + + protoFiles := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".proto") { + protoFiles = append(protoFiles, name) + } + } + if len(protoFiles) == 0 { + return errors.New("no generated proto files found") + } + sort.Strings(protoFiles) + + for _, file := range protoFiles { + body, readErr := os.ReadFile(filepath.Join(outputDir, file)) + if readErr != nil { + return fmt.Errorf("read generated proto failed: %s: %w", file, readErr) + } + lines := strings.Split(string(body), "\n") + for idx, line := range lines { + if placeholderRe.MatchString(line) && diag != nil { + hit := fmt.Sprintf("%s:%d: %s", file, idx+1, strings.TrimSpace(line)) + diag.placeholderHits = append(diag.placeholderHits, hit) + } + } + if err := validateRequiredAgentShapes(file, string(body)); err != nil { + return err + } + } + + parser := protoparse.Parser{ + ImportPaths: []string{outputDir}, + LookupImport: desc.LoadFileDescriptor, + } + if _, parseErr := parser.ParseFiles(protoFiles...); parseErr != nil { + return fmt.Errorf("parse generated proto failed: %w", parseErr) + } + + return nil +} + +func validateRequiredAgentShapes(file string, body string) error { + if strings.Contains(body, "message ExecClientControlMessage") && !streamCloseRe.MatchString(body) { + return fmt.Errorf("%s: ExecClientControlMessage.stream_close must be ExecClientStreamClose", file) + } + if strings.Contains(body, "message ShellStream") && !shellStdoutRe.MatchString(body) { + return fmt.Errorf("%s: ShellStream.stdout must be ShellStreamStdout", file) + } + return nil +} + +func extractMessages(text string, moduleStarts []int) []Message { + var messages []Message + + // Pattern 1: VarName = class InternalName extends l { ... this.typeName = "..." ... this.fields = ... } + // 先找所有 "变量名 = class 内部类名" 定义 + // JS 变量名可以包含 $ 符号,如 B$e, qg 等 + // 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个 + classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`) + classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1) + + // Pattern: this.typeName = "xxx.v1.YYY" (any package) + typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`) + + // Pattern: this.fields = n.util.newFieldList(() => [...]) + fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`) + + for _, classMatch := range classMatches { + varName := text[classMatch[2]:classMatch[3]] + internalName := text[classMatch[4]:classMatch[5]] + classStart := classMatch[0] + + // 找到类的结束位置(匹配大括号) + classEnd := findClassEnd(text, classMatch[1]-1) + if classEnd == -1 { + continue + } + + classBody := text[classStart:classEnd] + + // 在类体内查找 typeName + typeMatch := typeNameRe.FindStringSubmatch(classBody) + if typeMatch == nil { + continue + } + typeName := typeMatch[1] + + // 在类体内查找 fields + fieldsMatch := fieldsRe.FindStringIndex(classBody) + if fieldsMatch == nil { + continue + } + + // 找到 fields 数组的开始位置 + bracketPos := classStart + fieldsMatch[1] - 1 + fields := extractFieldArray(text, bracketPos) + + pkg, shortName := parseTypeName(typeName) + msg := Message{ + TypeName: typeName, + VarName: varName, + InternalName: internalName, + Fields: fields, + Package: pkg, + ShortName: shortName, + Pos: classStart, + ModuleStart: moduleStartForPos(moduleStarts, classStart), + } + messages = append(messages, msg) + } + + // Pattern 2: transpiled/minified bundle style + // Example: + // i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}]); + assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`) + assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1) + for _, m := range assignmentMatches { + varName := text[m[2]:m[3]] + typeName := text[m[4]:m[5]] + + // Skip duplicates already captured by class-body style + alreadyExists := false + for _, existing := range messages { + if existing.TypeName == typeName && existing.VarName == varName { + alreadyExists = true + break + } + } + if alreadyExists { + continue + } + + // Locate array start from the regex end (which stops right before '[') + start := m[1] - 1 + if start < 0 || start >= len(text) || text[start] != '[' { + continue + } + fields := extractFieldArray(text, start) + + pkg, shortName := parseTypeName(typeName) + messages = append(messages, Message{ + TypeName: typeName, + VarName: varName, + InternalName: "", + Fields: fields, + Package: pkg, + ShortName: shortName, + Pos: m[0], + ModuleStart: moduleStartForPos(moduleStarts, m[0]), + }) + } + + return messages +} + +// findClassEnd finds the matching closing brace for a class definition +func findClassEnd(text string, openBrace int) int { + depth := 0 + for i := openBrace; i < len(text); i++ { + if text[i] == '{' { + depth++ + } else if text[i] == '}' { + depth-- + if depth == 0 { + return i + 1 + } + } + } + return -1 +} + +func extractFieldArray(text string, start int) []Field { + // Find matching bracket + depth := 0 + end := start + for i := start; i < len(text); i++ { + if text[i] == '[' { + depth++ + } else if text[i] == ']' { + depth-- + if depth == 0 { + end = i + 1 + break + } + } + } + + arrayText := text[start:end] + + // Parse individual field objects by extracting each {...} block + var fields []Field + + // Find each field object + fieldObjects := extractFieldObjects(arrayText) + + for _, fieldObj := range fieldObjects { + field, parseErr := parseFieldObject(fieldObj) + if parseErr != nil { + activeDiagnostics.addSkippedField(fieldObj, parseErr) + continue + } + activeDiagnostics.addParsedField() + fields = append(fields, *field) + } + + return fields +} + +// extractFieldObjects extracts individual {...} objects from array text +func extractFieldObjects(arrayText string) []string { + var objects []string + depth := 0 + start := -1 + + for i := 0; i < len(arrayText); i++ { + if arrayText[i] == '{' { + if depth == 0 { + start = i + } + depth++ + } else if arrayText[i] == '}' { + depth-- + if depth == 0 && start >= 0 { + objects = append(objects, arrayText[start:i+1]) + start = -1 + } + } + } + + return objects +} + +// parseFieldObject parses a single field object like { no: 1, name: "foo", kind: "scalar", T: 9, opt: !0 } +func parseFieldObject(obj string) (*Field, error) { + // Extract no + noMatch := noRe.FindStringSubmatch(obj) + if noMatch == nil { + return nil, errors.New("missing field no") + } + no, _ := strconv.Atoi(noMatch[1]) + + // Extract name + nameMatch := nameRe.FindStringSubmatch(obj) + if nameMatch == nil { + return nil, errors.New("missing field name") + } + name := strings.TrimSpace(nameMatch[1]) + if !fieldNameRe.MatchString(name) { + return nil, fmt.Errorf("invalid field name: %s", name) + } + + // Extract kind + kindMatch := kindRe.FindStringSubmatch(obj) + if kindMatch == nil { + return nil, errors.New("missing field kind") + } + kind := strings.TrimSpace(kindMatch[1]) + + field := &Field{ + No: no, + Name: name, + Kind: kind, + } + + // Extract T (type) - can be: + // 1. number (scalar): T: 9 + // 2. variable name: T: SPe + // 3. getEnumType call: T: n.getEnumType(SPe) or T: n.proto3.getEnumType(SPe) + + // Try getEnumType pattern first (for enums) + if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil { + field.T = enumMatch[1] + } else { + // Try simple T: value pattern + if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil { + if t, err := strconv.Atoi(tMatch[1]); err == nil { + field.T = t + } else { + field.T = tMatch[1] + } + } + } + + // Check for oneof (within THIS object only) + if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil { + candidate := strings.TrimSpace(oneofMatch[1]) + if oneofNameRe.MatchString(candidate) { + field.Oneof = candidate + } + } + + // Check for repeated (within THIS object only) + // !0 means true in minified JS + if repeatedRe.MatchString(obj) { + field.Repeated = true + } + + // Check for optional (within THIS object only) + if optRe.MatchString(obj) { + field.Opt = true + } + + // Check for map type: K: keyType, V: { kind: "scalar"|"message", T: valueType } + if field.Kind == "map" { + // Extract K (key type) + if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil { + field.MapKey, _ = strconv.Atoi(keyMatch[1]) + } + + // Extract V (value type) - property order can vary. + if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil { + valueObj := valueMatch[1] + if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil { + field.MapValueKind = kindMatch[1] + } + if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil { + if t, err := strconv.Atoi(tMatch[1]); err == nil { + field.MapValueT = t + } else { + field.MapValueT = tMatch[1] + } + } + } + } + + return field, nil +} + +func extractEnums(text string, moduleStarts []int) []Enum { + var enums []Enum + + // Pattern for enum: setEnumType(XXX, "xxx.v1.EnumName", [...]) (any package) + // JS 变量名可以包含 $ 符号 + enumRe := regexp.MustCompile(`setEnumType\s*\(\s*([\w$]+)\s*,\s*"([\w.]+)"\s*,\s*\[`) + + matches := enumRe.FindAllStringSubmatchIndex(text, -1) + for _, match := range matches { + varName := text[match[2]:match[3]] + typeName := text[match[4]:match[5]] + + // Extract enum values array + bracketStart := match[1] - 1 + values := extractEnumValues(text, bracketStart) + + pkg, shortName := parseTypeName(typeName) + enum := Enum{ + TypeName: typeName, + VarName: varName, + Values: values, + Package: pkg, + ShortName: shortName, + Pos: match[0], + ModuleStart: moduleStartForPos(moduleStarts, match[0]), + } + enums = append(enums, enum) + } + + return enums +} + +func extractServices(text string, moduleStarts []int) []Service { + var services []Service + + // Pattern: VarName = { typeName: "xxx.v1.ServiceName", methods: { ... } } + // Service definitions are object literals, not classes + serviceRe := regexp.MustCompile(`([\w$]+)\s*=\s*\{\s*typeName:\s*"([\w.]+)"\s*,\s*methods:\s*\{`) + + matches := serviceRe.FindAllStringSubmatchIndex(text, -1) + for _, match := range matches { + varName := text[match[2]:match[3]] + typeName := text[match[4]:match[5]] + + // Find the end of the methods object + methodsStart := match[1] - 1 // position of '{' + methodsEnd := findMatchingBrace(text, methodsStart) + if methodsEnd == -1 { + continue + } + + methodsText := text[methodsStart:methodsEnd] + methods := extractMethods(methodsText) + + pkg, shortName := parseTypeName(typeName) + service := Service{ + TypeName: typeName, + VarName: varName, + Methods: methods, + Package: pkg, + ShortName: shortName, + Pos: match[0], + ModuleStart: moduleStartForPos(moduleStarts, match[0]), + } + services = append(services, service) + } + + return services +} + +func extractMethods(methodsText string) []Method { + var methods []Method + + // Pattern: methodName: { name: "MethodName", I: n.Input, O: n.Output, kind: s.MethodKind.Unary } + methodRe := regexp.MustCompile(`\w+:\s*\{\s*name:\s*"([^"]+)"\s*,\s*I:\s*([\w$.]+)\s*,\s*O:\s*([\w$.]+)\s*,\s*kind:\s*[\w$.]+\.(Unary|ServerStreaming|ClientStreaming|BiDiStreaming)`) + + matches := methodRe.FindAllStringSubmatch(methodsText, -1) + for _, m := range matches { + method := Method{ + Name: m[1], + InputType: m[2], + OutputType: m[3], + Kind: m[4], + } + methods = append(methods, method) + } + + return methods +} + +func findMatchingBrace(text string, start int) int { + depth := 0 + for i := start; i < len(text); i++ { + if text[i] == '{' { + depth++ + } else if text[i] == '}' { + depth-- + if depth == 0 { + return i + 1 + } + } + } + return -1 +} + +func extractEnumValues(text string, start int) []EnumValue { + // Find matching bracket + depth := 0 + end := start + for i := start; i < len(text); i++ { + if text[i] == '[' { + depth++ + } else if text[i] == ']' { + depth-- + if depth == 0 { + end = i + 1 + break + } + } + } + + arrayText := text[start:end] + + var values []EnumValue + valueRe := regexp.MustCompile(`\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"`) + + matches := valueRe.FindAllStringSubmatch(arrayText, -1) + for _, m := range matches { + no, _ := strconv.Atoi(m[1]) + values = append(values, EnumValue{No: no, Name: m[2]}) + } + + return values +} + +func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) { + os.MkdirAll(outputDir, 0755) + + // Group by package + packages := make(map[string]struct { + messages []Message + enums []Enum + services []Service + }) + + for _, msg := range messages { + pkg := packages[msg.Package] + pkg.messages = append(pkg.messages, msg) + packages[msg.Package] = pkg + } + + for _, enum := range enums { + pkg := packages[enum.Package] + pkg.enums = append(pkg.enums, enum) + packages[enum.Package] = pkg + } + + for _, svc := range services { + pkg := packages[svc.Package] + pkg.services = append(pkg.services, svc) + packages[svc.Package] = pkg + } + + // Build global type maps for copying + allMessages := make(map[string]*Message) + allEnums := make(map[string]*Enum) + + for pkgName, pkg := range packages { + if isGooglePkg(pkgName) { + continue + } + for i := range pkg.messages { + msg := &pkg.messages[i] + allMessages[msg.TypeName] = msg + } + for i := range pkg.enums { + enum := &pkg.enums[i] + allEnums[enum.TypeName] = enum + } + } + + // Reset copiedTypes tracking + copiedTypes = make(map[string]map[string]string) + + for pkgName, pkg := range packages { + // Skip Google standard packages - use official proto files instead + if isGooglePkg(pkgName) { + fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName) + continue + } + + // Copy all external types referenced by this package + augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums) + generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir) + } +} + +// copyAllExternalTypes copies all externally referenced types into the current package +func copyAllExternalTypes(pkgName string, pkg struct { + messages []Message + enums []Enum + services []Service +}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct { + messages []Message + enums []Enum + services []Service +} { + if copiedTypes[pkgName] == nil { + copiedTypes[pkgName] = make(map[string]string) + } + + // Build set of types already in this package + // Also record them in copiedTypes so resolveFieldTypeWithPkg can use local names + localTypes := make(map[string]bool) + for _, msg := range pkg.messages { + localTypes[msg.ShortName] = true + // Mark as "local" - empty string means original type in this package + if copiedTypes[pkgName][msg.ShortName] == "" { + copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName + } + } + for _, enum := range pkg.enums { + localTypes[enum.ShortName] = true + if copiedTypes[pkgName][enum.ShortName] == "" { + copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName + } + } + + // Result starts with original types + result := struct { + messages []Message + enums []Enum + services []Service + }{ + messages: append([]Message{}, pkg.messages...), + enums: append([]Enum{}, pkg.enums...), + services: pkg.services, + } + + totalCopied := 0 + + // Iterate until no new types need to be copied + for round := 1; ; round++ { + // Collect all external type references from current messages + neededTypes := make(map[string]bool) + + for _, msg := range result.messages { + for _, f := range msg.Fields { + collectFieldRefsSimple(f, pkgName, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes) + } + } + for _, svc := range result.services { + for _, m := range svc.Methods { + collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes) + collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes) + } + } + + // Copy needed types + copiedThisRound := 0 + for typeName := range neededTypes { + refPkg, shortName := parseTypeName(typeName) + if refPkg == pkgName || isGooglePkg(refPkg) { + continue + } + + // Check if already local + if localTypes[shortName] { + continue + } + + // Copy message + if msg, ok := allMessages[typeName]; ok { + msgCopy := *msg + msgCopy.Package = pkgName + // Keep original TypeName for source reference in comments + // msgCopy.TypeName will be used for reference, store original separately + result.messages = append(result.messages, msgCopy) + copiedTypes[pkgName][shortName] = typeName // original full type name + localTypes[shortName] = true + copiedThisRound++ + fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName) + } else if enum, ok := allEnums[typeName]; ok { + // Copy enum + enumCopy := *enum + enumCopy.Package = pkgName + result.enums = append(result.enums, enumCopy) + copiedTypes[pkgName][shortName] = typeName + localTypes[shortName] = true + copiedThisRound++ + fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName) + } else { + // Type not found - add to copiedTypes anyway to use local reference + // This handles cases where the type exists locally but wasn't in our extraction + copiedTypes[pkgName][shortName] = typeName + localTypes[shortName] = true + fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName) + } + } + + totalCopied += copiedThisRound + + if copiedThisRound == 0 { + break // No more types to copy + } + + if round > 20 { + fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName) + break + } + } + + if totalCopied > 0 { + fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied) + } + + return result +} + +// collectFieldRefsSimple collects external type references from a field (non-recursive, just this field) +func collectFieldRefsSimple(f Field, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver, + neededTypes map[string]bool, localTypes map[string]bool) { + + type refWithKind struct { + ref string + kind string + } + + var refs []refWithKind + if f.Kind == "message" || f.Kind == "enum" { + if v, ok := f.T.(string); ok { + refs = append(refs, refWithKind{ref: v, kind: f.Kind}) + } + } + if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") { + if v, ok := f.MapValueT.(string); ok { + refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind}) + } + } + + for _, item := range refs { + typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, currentPkg, item.kind) + if !ok { + continue + } + + refPkg, shortName := parseTypeName(typeName) + if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) { + continue + } + + // Skip if already local + if localTypes[shortName] { + continue + } + + neededTypes[typeName] = true + } +} + +// collectMethodRefsSimple collects external type references from a method type +func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver, + neededTypes map[string]bool, localTypes map[string]bool) { + + typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message") + if !ok { + return + } + + refPkg, shortName := parseTypeName(typeName) + if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) { + return + } + + if localTypes[shortName] { + return + } + + neededTypes[typeName] = true +} + +// Global map to track copied types: targetPkg -> shortName -> original typeName +var copiedTypes = make(map[string]map[string]string) + +// TypeNode represents a node in the nested type tree +type TypeNode struct { + Name string + Message *Message + Enum *Enum + Children map[string]*TypeNode +} + +// collectImports collects only Google standard imports (all other types are copied locally) +func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool { + imports := make(map[string]bool) + + addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) { + typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind) + if !ok { + return + } + + refPkg, shortName := parseTypeName(typeName) + // Only import Google standard types - all others are copied locally + if refPkg == "google.protobuf" { + var importFile string + switch shortName { + case "Struct", "Value", "ListValue", "NullValue": + importFile = "google/protobuf/struct.proto" + case "Timestamp": + importFile = "google/protobuf/timestamp.proto" + case "Duration": + importFile = "google/protobuf/duration.proto" + case "Any": + importFile = "google/protobuf/any.proto" + case "Empty": + importFile = "google/protobuf/empty.proto" + case "FieldMask": + importFile = "google/protobuf/field_mask.proto" + case "BoolValue", "BytesValue", "DoubleValue", "FloatValue", + "Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value": + importFile = "google/protobuf/wrappers.proto" + default: + importFile = "google/protobuf/descriptor.proto" + } + imports[importFile] = true + } else if refPkg == "google.rpc" { + var importFile string + switch shortName { + case "Status": + importFile = "google/rpc/status.proto" + case "Code": + importFile = "google/rpc/code.proto" + default: + importFile = "google/rpc/status.proto" + } + imports[importFile] = true + } + } + + for _, msg := range messages { + for _, f := range msg.Fields { + if f.Kind == "message" || f.Kind == "enum" { + if ref, ok := f.T.(string); ok { + addImport(ref, msg.Pos, msg.ModuleStart, f.Kind) + } + } + // Also check map value types + if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") { + if ref, ok := f.MapValueT.(string); ok { + addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind) + } + } + } + } + + for _, svc := range services { + for _, m := range svc.Methods { + addImport(m.InputType, svc.Pos, svc.ModuleStart, "message") + addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message") + } + } + + return imports +} + +func generateProtoFile(pkgName string, messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) { + // First, collect all cross-package imports + imports := collectImports(pkgName, messages, services, resolver) + + var sb strings.Builder + + sb.WriteString(`syntax = "proto3";` + "\n\n") + sb.WriteString(fmt.Sprintf("package %s;\n\n", pkgName)) + + // Write imports + if len(imports) > 0 { + sortedImports := make([]string, 0, len(imports)) + for imp := range imports { + sortedImports = append(sortedImports, imp) + } + sort.Strings(sortedImports) + for _, imp := range sortedImports { + sb.WriteString(fmt.Sprintf("import \"%s\";\n", imp)) + } + sb.WriteString("\n") + } + + goPackagePath := strings.ReplaceAll(pkgName, ".", "/") + goPackageName := strings.ReplaceAll(pkgName, ".", "") + sb.WriteString(fmt.Sprintf(`option go_package = "react-admin/cursor-server/gen/%s;%s";`+"\n\n", goPackagePath, goPackageName)) + + // Build type tree + root := &TypeNode{Children: make(map[string]*TypeNode)} + + for i := range messages { + msg := &messages[i] + path := getNestedPath(msg.ShortName) + insertMessage(root, path, msg) + } + + for i := range enums { + enum := &enums[i] + path := getNestedPath(enum.ShortName) + insertEnum(root, path, enum) + } + + // Write all top-level types + writeTypeTree(root, &sb, resolver, 0, pkgName) + + // Write services + sort.Slice(services, func(i, j int) bool { + return services[i].ShortName < services[j].ShortName + }) + + for _, svc := range services { + // Write source comment for service + sb.WriteString(fmt.Sprintf("// Source: %s (var: %s)\n", svc.TypeName, svc.VarName)) + sb.WriteString(fmt.Sprintf("service %s {\n", svc.ShortName)) + for _, m := range svc.Methods { + inputType := resolveMethodType(m.InputType, resolver, pkgName, svc.Pos, svc.ModuleStart) + outputType := resolveMethodType(m.OutputType, resolver, pkgName, svc.Pos, svc.ModuleStart) + + switch m.Kind { + case "ServerStreaming": + sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (stream %s) {}\n", m.Name, inputType, outputType)) + case "ClientStreaming": + sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (%s) {}\n", m.Name, inputType, outputType)) + case "BiDiStreaming": + sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (stream %s) {}\n", m.Name, inputType, outputType)) + default: // Unary + sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (%s) {}\n", m.Name, inputType, outputType)) + } + } + sb.WriteString("}\n\n") + } + + // Write to file - single flat directory + fileName := strings.ReplaceAll(pkgName, ".", "_") + ".proto" + filePath := filepath.Join(outputDir, fileName) + + os.WriteFile(filePath, []byte(sb.String()), 0644) + fmt.Printf("Generated: %s (%d messages, %d enums, %d services)\n", filePath, len(messages), len(enums), len(services)) +} + +func resolveMethodType(ref string, resolver *TypeResolver, currentPkg string, contextPos int, contextModuleStart int) string { + typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message") + if !ok { + activeDiagnostics.addUnresolvedType("method:" + ref) + return fallbackTypeToken(ref) + } + + refPkg, shortName := parseTypeName(typeName) + if refPkg == currentPkg || refPkg == "" { + return shortName + } + // Check if this type was copied to current package + if copied := copiedTypes[currentPkg]; copied != nil { + if _, isCopied := copied[shortName]; isCopied { + return shortName + } + } + return refPkg + "." + shortName +} + +func insertMessage(node *TypeNode, path []string, msg *Message) { + if len(path) == 0 { + return + } + + name := path[0] + if node.Children == nil { + node.Children = make(map[string]*TypeNode) + } + + child, exists := node.Children[name] + if !exists { + child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)} + node.Children[name] = child + } + + if len(path) == 1 { + child.Message = msg + } else { + insertMessage(child, path[1:], msg) + } +} + +func insertEnum(node *TypeNode, path []string, enum *Enum) { + if len(path) == 0 { + return + } + + name := path[0] + if node.Children == nil { + node.Children = make(map[string]*TypeNode) + } + + child, exists := node.Children[name] + if !exists { + child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)} + node.Children[name] = child + } + + if len(path) == 1 { + child.Enum = enum + } else { + insertEnum(child, path[1:], enum) + } +} + +func writeTypeTree(node *TypeNode, sb *strings.Builder, resolver *TypeResolver, indent int, currentPkg string) { + // Get sorted child names + var names []string + for name := range node.Children { + names = append(names, name) + } + sort.Strings(names) + + indentStr := strings.Repeat(" ", indent) + + for _, name := range names { + child := node.Children[name] + + if child.Enum != nil { + // Check if this is a copied type + originalType := "" + if copied := copiedTypes[currentPkg]; copied != nil { + if orig, ok := copied[child.Enum.ShortName]; ok { + originalType = orig + } + } + + // Write source comment for enum + if originalType != "" { + sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, child.Enum.VarName)) + } else { + sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Enum.TypeName, child.Enum.VarName)) + } + // Write enum + sb.WriteString(fmt.Sprintf("%senum %s {\n", indentStr, name)) + for _, v := range child.Enum.Values { + sb.WriteString(fmt.Sprintf("%s %s = %d;\n", indentStr, v.Name, v.No)) + } + sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr)) + } else if child.Message != nil || len(child.Children) > 0 { + // Write source comment for message + if child.Message != nil { + varInfo := child.Message.VarName + if child.Message.InternalName != "" && child.Message.InternalName != child.Message.VarName { + varInfo = fmt.Sprintf("%s, class: %s", child.Message.VarName, child.Message.InternalName) + } + + // Check if this is a copied type + originalType := "" + if copied := copiedTypes[currentPkg]; copied != nil { + if orig, ok := copied[child.Message.ShortName]; ok { + originalType = orig + } + } + + if originalType != "" { + sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, varInfo)) + } else { + sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Message.TypeName, varInfo)) + } + } + // Write message (even if just a container for nested types) + sb.WriteString(fmt.Sprintf("%smessage %s {\n", indentStr, name)) + + // Write nested types first + writeTypeTree(child, sb, resolver, indent+1, currentPkg) + + // Write fields if this node has a message + if child.Message != nil { + writeMessageFields(child.Message, sb, resolver, indent+1) + } + + sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr)) + } + } +} + +func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolver, indent int) { + indentStr := strings.Repeat(" ", indent) + + // Get the current message's path prefix for relative type resolution + msgPath := msg.ShortName + currentPkg := msg.Package + + // Group fields by oneof + oneofGroups := make(map[string][]Field) + var regularFields []Field + + for _, f := range msg.Fields { + if f.Oneof != "" { + oneofGroups[f.Oneof] = append(oneofGroups[f.Oneof], f) + } else { + regularFields = append(regularFields, f) + } + } + + // Write regular fields + for _, f := range regularFields { + fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart) + prefix := "" + if f.Repeated { + prefix = "repeated " + } else if f.Opt { + prefix = "optional " + } + sb.WriteString(fmt.Sprintf("%s%s%s %s = %d;\n", indentStr, prefix, fieldType, f.Name, f.No)) + } + + // Write oneof groups + var oneofNames []string + for name := range oneofGroups { + oneofNames = append(oneofNames, name) + } + sort.Strings(oneofNames) + + for _, oneofName := range oneofNames { + fields := oneofGroups[oneofName] + sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName)) + for _, f := range fields { + fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, msg.Pos, msg.ModuleStart) + sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No)) + } + sb.WriteString(fmt.Sprintf("%s}\n", indentStr)) + } +} + +// parseTypeName extracts package and full nested path from type name +// "agent.v1.Foo" -> ("agent.v1", "Foo") +// "agent.v1.Foo.Bar" -> ("agent.v1", "Foo.Bar") +// "anyrun.v1.PodStatus" -> ("anyrun.v1", "PodStatus") +// "google.protobuf.Timestamp" -> ("google.protobuf", "Timestamp") +func parseTypeName(typeName string) (pkg, shortName string) { + // Find pattern: xxx.v1.Rest or xxx.vN.Rest + versionRe := regexp.MustCompile(`^([\w.]+\.v\d+)\.(.+)$`) + if match := versionRe.FindStringSubmatch(typeName); match != nil { + return match[1], match[2] + } + + // Handle google.protobuf.XXX pattern + if strings.HasPrefix(typeName, "google.protobuf.") { + rest := strings.TrimPrefix(typeName, "google.protobuf.") + return "google.protobuf", rest + } + + // Handle google.rpc.XXX pattern + if strings.HasPrefix(typeName, "google.rpc.") { + rest := strings.TrimPrefix(typeName, "google.rpc.") + return "google.rpc", rest + } + + // Fallback: split at last dot + parts := strings.Split(typeName, ".") + if len(parts) > 1 { + return strings.Join(parts[:len(parts)-1], "."), parts[len(parts)-1] + } + return "", typeName +} + +// getNestedPath returns the path components for a nested type +// "Foo" -> ["Foo"] +// "Foo.Bar" -> ["Foo", "Bar"] +// "Foo.Bar.Baz" -> ["Foo", "Bar", "Baz"] +func getNestedPath(shortName string) []string { + return strings.Split(shortName, ".") +} + +func resolveFieldType(f Field, resolver *TypeResolver, contextPos int, contextModuleStart int) string { + return resolveFieldTypeWithPkg(f, resolver, "", "", contextPos, contextModuleStart) +} + +// resolveFieldTypeWithPkg resolves field type with package awareness +// parentPath is like "ConversationMessage" or "ConversationMessage.ToolResult" +// currentPkg is the package of the current message being written (e.g., "agent.v1") +func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, contextPos int, contextModuleStart int) string { + resolveNamedType := func(ref string, expectedKind string) string { + typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind) + if !ok { + activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref) + return fallbackTypeToken(ref) + } + + refPkg, shortName := parseTypeName(typeName) + + // If the type is nested under the same parent, use relative path + if parentPath != "" && strings.HasPrefix(shortName, parentPath+".") { + // ConversationMessage.CodeChunk -> CodeChunk (when inside ConversationMessage) + return strings.TrimPrefix(shortName, parentPath+".") + } + + // If same package, use short name only + if refPkg == currentPkg || refPkg == "" { + return shortName + } + + // Check if this type was copied to current package (circular import resolution) + if copied := copiedTypes[currentPkg]; copied != nil { + if _, isCopied := copied[shortName]; isCopied { + // This type exists locally as a copy, use short name + return shortName + } + } + + // For cross-package references, use full type name + return refPkg + "." + shortName + } + + if f.Kind == "scalar" { + if t, ok := f.T.(int); ok { + return scalarTypes[t] + } + if t, ok := f.T.(float64); ok { + return scalarTypes[int(t)] + } + } + + if f.Kind == "message" || f.Kind == "enum" { + if ref, ok := f.T.(string); ok { + return resolveNamedType(ref, f.Kind) + } + } + + if f.Kind == "map" { + // Handle map types: map + keyType := scalarTypes[f.MapKey] + if keyType == "" { + keyType = "string" // default + } + + var valueType string + if f.MapValueKind == "scalar" { + if t, ok := f.MapValueT.(int); ok { + valueType = scalarTypes[t] + } else if t, ok := f.MapValueT.(float64); ok { + valueType = scalarTypes[int(t)] + } + } else if f.MapValueKind == "message" || f.MapValueKind == "enum" { + if ref, ok := f.MapValueT.(string); ok { + valueType = resolveNamedType(ref, f.MapValueKind) + } + } + if valueType == "" { + valueType = "bytes" + } + + return fmt.Sprintf("map<%s, %s>", keyType, valueType) + } + + return "bytes" // fallback +} diff --git a/cmd/ext-tool/main.go b/cmd/ext-tool/main.go new file mode 100644 index 0000000..aef18fe --- /dev/null +++ b/cmd/ext-tool/main.go @@ -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 [-output ] [-skip-format]") + fmt.Fprintln(os.Stderr, " ext ") + 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) +} diff --git a/cursor-tab-server/.dockerignore b/cursor-tab-server/.dockerignore new file mode 100644 index 0000000..f6f525c --- /dev/null +++ b/cursor-tab-server/.dockerignore @@ -0,0 +1,2 @@ +cursor-tab-server-linux-amd64.tar +*.tar diff --git a/cursor-tab-server/Dockerfile b/cursor-tab-server/Dockerfile new file mode 100644 index 0000000..e1cb05e --- /dev/null +++ b/cursor-tab-server/Dockerfile @@ -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"] diff --git a/cursor-tab-server/README.md b/cursor-tab-server/README.md new file mode 100644 index 0000000..262c7f6 --- /dev/null +++ b/cursor-tab-server/README.md @@ -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';" + + +``` \ No newline at end of file diff --git a/cursor-tab-server/config.yaml b/cursor-tab-server/config.yaml new file mode 100644 index 0000000..ecefbc3 --- /dev/null +++ b/cursor-tab-server/config.yaml @@ -0,0 +1 @@ +token: "eyJ..." diff --git a/cursor-tab-server/go.mod b/cursor-tab-server/go.mod new file mode 100644 index 0000000..b739c92 --- /dev/null +++ b/cursor-tab-server/go.mod @@ -0,0 +1,5 @@ +module cursor-tab-server + +go 1.25 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/cursor-tab-server/go.sum b/cursor-tab-server/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/cursor-tab-server/go.sum @@ -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= diff --git a/cursor-tab-server/main.go b/cursor-tab-server/main.go new file mode 100644 index 0000000..3038db0 --- /dev/null +++ b/cursor-tab-server/main.go @@ -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 +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..56f861c --- /dev/null +++ b/docs/architecture.md @@ -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 → 同上 +``` \ No newline at end of file diff --git a/docs/build-environment.md b/docs/build-environment.md new file mode 100644 index 0000000..99b5235 --- /dev/null +++ b/docs/build-environment.md @@ -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`。 diff --git a/frontend/.yarnrc b/frontend/.yarnrc new file mode 100644 index 0000000..0d244be --- /dev/null +++ b/frontend/.yarnrc @@ -0,0 +1,2 @@ +registry "https://registry.npmmirror.com" +network-timeout 120000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e3846cd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Local-Cursor + + +
+ + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..28f360e --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@bindings/*": ["bindings/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..628db1e --- /dev/null +++ b/frontend/package.json @@ -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" + ] +} diff --git a/frontend/plugins/static-i18n-plugin.js b/frontend/plugins/static-i18n-plugin.js new file mode 100644 index 0000000..ea747b0 --- /dev/null +++ b/frontend/plugins/static-i18n-plugin.js @@ -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, + }; + }, + }; +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/Inter-Medium.ttf b/frontend/public/Inter-Medium.ttf new file mode 100644 index 0000000..a01f377 Binary files /dev/null and b/frontend/public/Inter-Medium.ttf differ diff --git a/frontend/public/javascript.svg b/frontend/public/javascript.svg new file mode 100644 index 0000000..f9abb2b --- /dev/null +++ b/frontend/public/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/style.css b/frontend/public/style.css new file mode 100644 index 0000000..999818b --- /dev/null +++ b/frontend/public/style.css @@ -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; +} diff --git a/frontend/public/wails.png b/frontend/public/wails.png new file mode 100644 index 0000000..8bdf424 Binary files /dev/null and b/frontend/public/wails.png differ diff --git a/frontend/scripts/run-vite-build.mjs b/frontend/scripts/run-vite-build.mjs new file mode 100644 index 0000000..f6f6a39 --- /dev/null +++ b/frontend/scripts/run-vite-build.mjs @@ -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); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..2c0c93b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,34 @@ + + diff --git a/frontend/src/assets/Group 11.png b/frontend/src/assets/Group 11.png new file mode 100644 index 0000000..7a36b9d Binary files /dev/null and b/frontend/src/assets/Group 11.png differ diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000..90106db Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/HomeMetricsCard.vue b/frontend/src/components/HomeMetricsCard.vue new file mode 100644 index 0000000..53dff75 --- /dev/null +++ b/frontend/src/components/HomeMetricsCard.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/frontend/src/components/LocaleSelect.vue b/frontend/src/components/LocaleSelect.vue new file mode 100644 index 0000000..29e027e --- /dev/null +++ b/frontend/src/components/LocaleSelect.vue @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/components/ModelAdapterTestCard.vue b/frontend/src/components/ModelAdapterTestCard.vue new file mode 100644 index 0000000..5c7ace2 --- /dev/null +++ b/frontend/src/components/ModelAdapterTestCard.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/src/components/charts/CacheHitRateChart.vue b/frontend/src/components/charts/CacheHitRateChart.vue new file mode 100644 index 0000000..cfa0d3e --- /dev/null +++ b/frontend/src/components/charts/CacheHitRateChart.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/components/ui/Button.vue b/frontend/src/components/ui/Button.vue new file mode 100644 index 0000000..4ba4f75 --- /dev/null +++ b/frontend/src/components/ui/Button.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ui/Card.vue b/frontend/src/components/ui/Card.vue new file mode 100644 index 0000000..ac6433a --- /dev/null +++ b/frontend/src/components/ui/Card.vue @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/frontend/src/components/ui/Input.vue b/frontend/src/components/ui/Input.vue new file mode 100644 index 0000000..b231888 --- /dev/null +++ b/frontend/src/components/ui/Input.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/components/ui/InputModal.vue b/frontend/src/components/ui/InputModal.vue new file mode 100644 index 0000000..fbdc744 --- /dev/null +++ b/frontend/src/components/ui/InputModal.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/ui/MessageProvider.vue b/frontend/src/components/ui/MessageProvider.vue new file mode 100644 index 0000000..fae0452 --- /dev/null +++ b/frontend/src/components/ui/MessageProvider.vue @@ -0,0 +1,83 @@ + + + + + + + + + + diff --git a/frontend/src/components/ui/Modal.vue b/frontend/src/components/ui/Modal.vue new file mode 100644 index 0000000..6fb20f3 --- /dev/null +++ b/frontend/src/components/ui/Modal.vue @@ -0,0 +1,82 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/ui/Select.vue b/frontend/src/components/ui/Select.vue new file mode 100644 index 0000000..425786e --- /dev/null +++ b/frontend/src/components/ui/Select.vue @@ -0,0 +1,347 @@ + + + diff --git a/frontend/src/components/ui/Switch.vue b/frontend/src/components/ui/Switch.vue new file mode 100644 index 0000000..0cc925f --- /dev/null +++ b/frontend/src/components/ui/Switch.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ui/Tooltip.vue b/frontend/src/components/ui/Tooltip.vue new file mode 100644 index 0000000..00e4f56 --- /dev/null +++ b/frontend/src/components/ui/Tooltip.vue @@ -0,0 +1,156 @@ + + + diff --git a/frontend/src/composables/useInputModal.js b/frontend/src/composables/useInputModal.js new file mode 100644 index 0000000..deb1696 --- /dev/null +++ b/frontend/src/composables/useInputModal.js @@ -0,0 +1,36 @@ +import { reactive } from "vue"; + +export const inputModalState = reactive({ + visible: false, + title: "提示", + content: "", + placeholder: "", + value: "", + _resolve: null, +}); + +/** + * 显示输入弹窗,返回 Promise + * @param {Object} options - { title, content, placeholder, defaultValue } + * @returns {Promise} - 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 = ""; + } +} diff --git a/frontend/src/composables/useMessage.js b/frontend/src/composables/useMessage.js new file mode 100644 index 0000000..0bdf70b --- /dev/null +++ b/frontend/src/composables/useMessage.js @@ -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 }; \ No newline at end of file diff --git a/frontend/src/composables/useModal.js b/frontend/src/composables/useModal.js new file mode 100644 index 0000000..973e2eb --- /dev/null +++ b/frontend/src/composables/useModal.js @@ -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 + * @param {Object} options - { title, content } + * @returns {Promise} - 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); +} diff --git a/frontend/src/i18n/config.js b/frontend/src/i18n/config.js new file mode 100644 index 0000000..bca981b --- /dev/null +++ b/frontend/src/i18n/config.js @@ -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" }, +]; diff --git a/frontend/src/i18n/generated/catalog.json b/frontend/src/i18n/generated/catalog.json new file mode 100644 index 0000000..5366d9f --- /dev/null +++ b/frontend/src/i18n/generated/catalog.json @@ -0,0 +1,2412 @@ +{ + "entries": { + "02216368edc68816": { + "source": "无更新说明", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 831, + "column": 42 + } + ] + }, + "02bc2e95bf49e587": { + "source": "当前还没有配置任何", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 35, + "column": 165 + } + ] + }, + "03b11112dc970014": { + "source": "接口地址", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 82, + "column": 22 + } + ] + }, + "045261cc748d4300": { + "source": "Anthropic 思考阶段允许消耗的预算 Token 数。留空时使用默认值。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 90, + "column": 25 + } + ] + }, + "047ec6b71d0cec08": { + "source": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 21, + "column": 48 + } + ] + }, + "04f632dd4f034d5e": { + "source": "{0} 的上下文窗口必须为正整数", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 318, + "column": 14 + } + ] + }, + "051836569928a9f9": { + "source": "编辑", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 88, + "column": 139 + } + ] + }, + "054d763265603305": { + "source": "例如:65536(留空用默认值)", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 126, + "column": 16 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 143, + "column": 16 + } + ] + }, + "05c8a9238c702efa": { + "source": "直连 Cursor 模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 39, + "column": 12 + } + ] + }, + "0647728439b5da2e": { + "source": "可配置运行模式和模型渠道;运行日志位于", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 7, + "column": 48 + } + ] + }, + "092b520558eff5f2": { + "source": "未测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 75, + "column": 20 + } + ] + }, + "0b0e7478e41fe677": { + "source": "{0} 的悬停提示不能为空", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 309, + "column": 14 + } + ] + }, + "0c3b4cf7aa259edb": { + "source": "操作失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 67, + "column": 30 + }, + { + "file": "src/layouts/MainLayout.vue", + "line": 67, + "column": 48 + } + ] + }, + "0dde813d719dbd01": { + "source": "打开主页失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 90, + "column": 27 + } + ] + }, + "0e40a09ab4e664ab": { + "source": "模型渠道重复,请检查 url、modelID、apiKey、displayName 组合", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 331, + "column": 14 + } + ] + }, + "1117a2f86030d03b": { + "source": "缓存读写已计入 Prompt 侧统计。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 94, + "column": 5 + } + ] + }, + "11afd2a534395b18": { + "source": "有效", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 49, + "column": 65 + } + ] + }, + "124be3f86f197802": { + "source": "Token 消耗", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 65, + "column": 20 + } + ] + }, + "13b61c5f697b6700": { + "source": "缓存命中率", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 30, + "column": 20 + } + ] + }, + "15d124b200ddabed": { + "source": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 86, + "column": 24 + } + ] + }, + "18b7312022cd1840": { + "source": "启动服务", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 802, + "column": 47 + } + ] + }, + "1af38868896cf53d": { + "source": "运行模式仅支持 local 或 upstream", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 340, + "column": 12 + } + ] + }, + "1b7d24b212e52c54": { + "source": "{0} 的推理强度仅支持 low、medium、high、xhigh", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 315, + "column": 14 + } + ] + }, + "1bc77f5ab979f4c1": { + "source": "新增模型配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 79, + "column": 67 + } + ] + }, + "1e238093b79b3165": { + "source": "留空时默认 65536", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 91, + "column": 22 + }, + { + "file": "src/components/ModelAdapterModal.vue", + "line": 104, + "column": 22 + } + ] + }, + "24343a2096988d42": { + "source": "打开失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 42, + "column": 27 + }, + { + "file": "src/views/Home.vue", + "line": 57, + "column": 27 + }, + { + "file": "src/views/Home.vue", + "line": 65, + "column": 27 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 105, + "column": 27 + } + ] + }, + "253d4a3428c648fb": { + "source": "缓存写入:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 66, + "column": 5 + }, + { + "file": "src/components/HomeMetricsCard.vue", + "line": 92, + "column": 5 + } + ] + }, + "258e4620e2108793": { + "source": "留空时默认 4096", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 115, + "column": 22 + } + ] + }, + "26a3855aed1d8d17": { + "source": "服务未启动", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 793, + "column": 12 + } + ] + }, + "281eb6d08c9960d0": { + "source": "{0} 的思考预算 Token 必须为正整数", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 327, + "column": 14 + } + ] + }, + "28aeffc70ceb4267": { + "source": "切换当前界面显示语言,设置会立即生效并保存在本机", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 39, + "column": 48 + } + ] + }, + "2caeaec539e78898": { + "source": "思考预算 Token", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 153, + "column": 22 + } + ] + }, + "2cd0f3be8738a86c": { + "source": "取消", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 138, + "column": 66 + }, + { + "file": "src/components/ui/InputModal.vue", + "line": 32, + "column": 66 + }, + { + "file": "src/components/ui/Modal.vue", + "line": 9, + "column": 40 + }, + { + "file": "src/composables/useModal.js", + "line": 8, + "column": 15 + }, + { + "file": "src/composables/useModal.js", + "line": 25, + "column": 51 + }, + { + "file": "src/state/appState.js", + "line": 843, + "column": 52 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 6, + "column": 58 + } + ] + }, + "2d706f7981b45a7b": { + "source": "本地配置已保存", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 34, + "column": 14 + } + ] + }, + "2f9daa828907b93f": { + "source": "删除", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 91, + "column": 95 + } + ] + }, + "30bb57a50caedc38": { + "source": "例如:用于日常代码补全与问答", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 173, + "column": 14 + } + ] + }, + "32b3c9a50003f77a": { + "source": "输出 Token 为估算值", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 49, + "column": 7 + } + ] + }, + "33d2d273e2bd5f88": { + "source": "打开使用教程失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 98, + "column": 27 + } + ] + }, + "3468b57e3edbc599": { + "source": "按历史记录里扫描到的回合 summary 汇总。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 75, + "column": 5 + } + ] + }, + "35076178fe79a210": { + "source": "配置已变更,请重新测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 28, + "column": 60 + } + ] + }, + "36c149a9b3e8dca0": { + "source": "模型。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 36, + "column": 47 + } + ] + }, + "37d23612f78a2e63": { + "source": "立即重启更新", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 840, + "column": 45 + } + ] + }, + "392d0dceb45998d3": { + "source": "极高", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 16, + "column": 12 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 33, + "column": 12 + } + ] + }, + "393df9bb13ea4900": { + "source": "命中", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/charts/CacheHitRateChart.vue", + "line": 76, + "column": 12 + } + ] + }, + "3af7e5489e61ea51": { + "source": "刷新中", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 14, + "column": 22 + } + ] + }, + "3bf8512aa520ed21": { + "source": "本地服务模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 38, + "column": 12 + } + ] + }, + "3c2a9f9901109e75": { + "source": "{0} 的类型仅支持 OpenAI 或 Anthropic", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 300, + "column": 14 + } + ] + }, + "3d13868593ae4eeb": { + "source": "界面语言", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 84, + "column": 33 + }, + { + "file": "src/views/Config.vue", + "line": 38, + "column": 57 + } + ] + }, + "3ea83f9f55062582": { + "source": "发布时间:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 829, + "column": 11 + } + ] + }, + "3edda85621fd03b2": { + "source": "个模型适配器", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 52, + "column": 53 + } + ] + }, + "42aa8e01e98c0d8c": { + "source": "总耗时", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 37, + "column": 75 + } + ] + }, + "468adaa418ee1475": { + "source": "例如:https://api.openai.com/v1", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 60, + "column": 65 + } + ] + }, + "4923eeb7bd75cccd": { + "source": "{0} 的模型标识不能为空", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 312, + "column": 14 + } + ] + }, + "497c85690c4cc0fc": { + "source": "暂无数据", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 41, + "column": 12 + } + ] + }, + "4b8d11bf235e9213": { + "source": "当前命中率:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 64, + "column": 5 + } + ] + }, + "4d8c1c5b42830791": { + "source": "未知", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 107, + "column": 12 + } + ] + }, + "51194c3ad014fb29": { + "source": "需重测", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 23, + "column": 9 + } + ] + }, + "5205125c0e91d346": { + "source": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 89, + "column": 23 + } + ] + }, + "56627c94a9decee6": { + "source": "最大输出 Token", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 120, + "column": 22 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 137, + "column": 22 + } + ] + }, + "5beb1206c532729f": { + "source": "单次回复允许生成的最大 Token 数。留空时使用默认值。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 88, + "column": 24 + } + ] + }, + "5d1687a4a41883fd": { + "source": "停止中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 40, + "column": 12 + } + ] + }, + "6106f0a12583a334": { + "source": "刷新失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 45, + "column": 27 + } + ] + }, + "62873083fcaed27d": { + "source": "会话统计", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 6, + "column": 62 + } + ] + }, + "6309a3bb5ba4c714": { + "source": "保存失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 29, + "column": 27 + } + ] + }, + "636e3deffc1e960a": { + "source": "模型 {0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 295, + "column": 20 + } + ] + }, + "63d90d977348ab1f": { + "source": "复制", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 89, + "column": 156 + } + ] + }, + "64d2730f2ae37997": { + "source": "原始返回", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 12, + "column": 20 + } + ] + }, + "65cc5fd2e6ce6e75": { + "source": "后端已启动,代理未启动", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 791, + "column": 14 + } + ] + }, + "66af574b8948fe83": { + "source": "{0} 的访问密钥不能为空", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 306, + "column": 14 + } + ] + }, + "675109292da4eb36": { + "source": "尚未测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 29, + "column": 14 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 76, + "column": 54 + } + ] + }, + "699fe7ade5407687": { + "source": "直连模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/DirectModeSwitch.vue", + "line": 6, + "column": 35 + } + ] + }, + "6a7b96f399e58138": { + "source": "例如:sk-xxxxxx", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 74, + "column": 16 + } + ] + }, + "6aa8f49cc992dfd7": { + "source": "测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 74, + "column": 20 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 86, + "column": 23 + } + ] + }, + "6ae23d6d7cb18592": { + "source": "服务错误", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 23, + "column": 31 + }, + { + "file": "src/views/Config.vue", + "line": 22, + "column": 30 + }, + { + "file": "src/views/Config.vue", + "line": 22, + "column": 48 + }, + { + "file": "src/views/Home.vue", + "line": 28, + "column": 30 + }, + { + "file": "src/views/Home.vue", + "line": 28, + "column": 48 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 63, + "column": 30 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 63, + "column": 48 + } + ] + }, + "6e584e3d5ce64aa0": { + "source": "保存配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 12, + "column": 15 + } + ] + }, + "737225e2904673fc": { + "source": "输出推算:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 90, + "column": 5 + } + ] + }, + "7520bd50a5ee5471": { + "source": "停止测试 {0}/{1}", + "kind": "template", + "placeholders": 2, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 45, + "column": 10 + } + ] + }, + "753d8bb0da9913ce": { + "source": "复制失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 124, + "column": 27 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 129, + "column": 27 + } + ] + }, + "77c9e582e85583af": { + "source": "测试失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 47, + "column": 12 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 179, + "column": 21 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 179, + "column": 21 + } + ] + }, + "7a26bf794e9fb6bf": { + "source": "仅用于界面展示,便于你区分不同模型。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 82, + "column": 16 + } + ] + }, + "7b6187c41e88b70c": { + "source": "测试中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 44, + "column": 12 + }, + { + "file": "src/state/appState.js", + "line": 193, + "column": 12 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 86, + "column": 23 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 8, + "column": 15 + } + ] + }, + "7bf8e2c07e084d09": { + "source": "模型编辑", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/router/index.js", + "line": 22, + "column": 39 + } + ] + }, + "7e9e334aeb0bdc07": { + "source": "服务操作失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 35, + "column": 27 + } + ] + }, + "7f68ebad19ba6bcd": { + "source": "检查更新", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 49, + "column": 16 + }, + { + "file": "src/state/appState.js", + "line": 821, + "column": 16 + } + ] + }, + "81123c56d5d880d0": { + "source": "访问密钥", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 68, + "column": 22 + } + ] + }, + "86df7ec743047234": { + "source": "服务运行中", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 788, + "column": 14 + } + ] + }, + "87ed126f7bd1121e": { + "source": "运行模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 20, + "column": 57 + } + ] + }, + "899add6275682210": { + "source": "留空时默认 200000", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 71, + "column": 20 + } + ] + }, + "8c0d84831a3c3d5b": { + "source": "当前为本地服务模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/DirectModeSwitch.vue", + "line": 12, + "column": 42 + } + ] + }, + "8c1935935600e336": { + "source": "模型测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 25, + "column": 14 + } + ] + }, + "8cbcf741e727dbf7": { + "source": "模型配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 21, + "column": 35 + }, + { + "file": "src/router/index.js", + "line": 17, + "column": 39 + }, + { + "file": "src/views/Config.vue", + "line": 50, + "column": 57 + }, + { + "file": "src/views/Home.vue", + "line": 49, + "column": 69 + } + ] + }, + "8d1de152be6360ce": { + "source": "有效占比:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 80, + "column": 5 + } + ] + }, + "8e2dc7b0d2e8f6f8": { + "source": "例如:OpenAI - GPT-4.1", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 47, + "column": 16 + } + ] + }, + "8f6f8d979c981ced": { + "source": "已复制", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/Tooltip.vue", + "line": 31, + "column": 23 + } + ] + }, + "8faa670b512b6b9b": { + "source": "打开模型配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 55, + "column": 67 + } + ] + }, + "917b1c1f18d0276b": { + "source": "保存中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 12, + "column": 15 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 11, + "column": 15 + } + ] + }, + "9196835e388d2550": { + "source": "测试全部", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 43, + "column": 12 + } + ] + }, + "91cba5c107a51892": { + "source": "/ 异常", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 53, + "column": 23 + } + ] + }, + "92059fe6cd713db4": { + "source": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 83, + "column": 12 + } + ] + }, + "93e08803675e378b": { + "source": "模型标识", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 55, + "column": 22 + } + ] + }, + "942ff2d88baca0c6": { + "source": "检查更新中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 46, + "column": 44 + } + ] + }, + "986678eccf56dc28": { + "source": "服务状态更新中,请稍后再试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 1064, + "column": 32 + }, + { + "file": "src/state/appState.js", + "line": 1085, + "column": 32 + } + ] + }, + "991e374fce0f4492": { + "source": "Local-Cursor — Based on Cursor-BYOK", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 16, + "column": 50 + }, + { + "file": "src/router/index.js", + "line": 12, + "column": 38 + } + ] + }, + "9970736b36ff2b68": { + "source": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 84, + "column": 12 + } + ] + }, + "9b17fa889b307f7f": { + "source": "有效轮次:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 78, + "column": 5 + } + ] + }, + "9c38b6e9bf94abec": { + "source": "已切换到直连 Cursor 模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 75, + "column": 29 + } + ] + }, + "9cd4ac17428b86e4": { + "source": "缓存命中率 = 缓存读取 Token / Prompt Token", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 62, + "column": 5 + } + ] + }, + "9d2ca261281a158a": { + "source": "稍后", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 843, + "column": 45 + } + ] + }, + "9dc0825fba5422e4": { + "source": "加载中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 16, + "column": 97 + } + ] + }, + "a026f37e613cf48b": { + "source": "输出 Token", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterTestCard.vue", + "line": 41, + "column": 75 + } + ] + }, + "a1a038dfa16c3ede": { + "source": "当前已是最新版本(v{0})。", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 836, + "column": 42 + } + ] + }, + "a3030bf8f16dc63c": { + "source": "保存", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 139, + "column": 64 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 11, + "column": 15 + } + ] + }, + "a325d25c69e7256d": { + "source": "模型配置不存在,无法复制", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 1009, + "column": 14 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 124, + "column": 35 + } + ] + }, + "a4dd8bb7e8b6eb31": { + "source": "显示访问密钥", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/Input.vue", + "line": 18, + "column": 21 + } + ] + }, + "a55a88237df85d98": { + "source": "当前为直连模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/DirectModeSwitch.vue", + "line": 11, + "column": 41 + } + ] + }, + "a567bdaa11367f26": { + "source": "中", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 14, + "column": 12 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 31, + "column": 12 + } + ] + }, + "a5f1bd344c92e195": { + "source": "调用该模型服务需要使用的访问密钥。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 85, + "column": 11 + } + ] + }, + "a693d69af48bfe48": { + "source": "保存并测试", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 8, + "column": 15 + } + ] + }, + "a98585871c5313ff": { + "source": "显示名称", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 21, + "column": 57 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 42, + "column": 22 + } + ] + }, + "aa9e366f68d3d097": { + "source": "低", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 13, + "column": 12 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 30, + "column": 12 + } + ] + }, + "ac217e4d1ca410f1": { + "source": "发现新版本", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 817, + "column": 16 + } + ] + }, + "ad79540418be700a": { + "source": "打开设置目录,或单独管理模型配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 45, + "column": 48 + } + ] + }, + "ae5a738238463a92": { + "source": "隐藏访问密钥", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/Input.vue", + "line": 18, + "column": 21 + } + ] + }, + "aed55419ce62f08e": { + "source": "切换中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/DirectModeSwitch.vue", + "line": 7, + "column": 13 + } + ] + }, + "b1c27820fec23edb": { + "source": "高", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 15, + "column": 12 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 32, + "column": 12 + } + ] + }, + "b42049dcf8a05ef7": { + "source": "已切换到本地服务模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 75, + "column": 50 + } + ] + }, + "b571037dc396a00c": { + "source": "总请求 Token 包含 Prompt 和模型输出。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 86, + "column": 5 + } + ] + }, + "b765005f69fa971f": { + "source": "例如:gpt-4.1", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 60, + "column": 16 + } + ] + }, + "b90a8ac9c488ce46": { + "source": "选择语言", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/LocaleSelect.vue", + "line": 9, + "column": 41 + } + ] + }, + "ba40014ff496f64e": { + "source": "类型", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ModelAdapterModal.vue", + "line": 39, + "column": 57 + } + ] + }, + "bb074b86a98f6911": { + "source": "上下文窗口", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 95, + "column": 22 + } + ] + }, + "bbacfde55a92869f": { + "source": "命中率越高,说明重复上下文复用得越多。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 69, + "column": 5 + } + ] + }, + "bc87a4121a0873b3": { + "source": "刷新统计", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 9, + "column": 18 + }, + { + "file": "src/components/HomeMetricsCard.vue", + "line": 14, + "column": 22 + } + ] + }, + "bd4464ea88d3f24a": { + "source": "总轮次:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 77, + "column": 5 + } + ] + }, + "bef280f9eb392495": { + "source": "对话轮次", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 38, + "column": 20 + } + ] + }, + "c228558cf257fc49": { + "source": "删除失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 112, + "column": 27 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 117, + "column": 27 + } + ] + }, + "c3e9c3c60020b8b7": { + "source": "选择模式", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 29, + "column": 14 + } + ] + }, + "c69f5bce63b9f14c": { + "source": "设置文件夹", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 48, + "column": 64 + } + ] + }, + "c6f743953145f40b": { + "source": "例如:4096(留空用默认值)", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 159, + "column": 16 + } + ] + }, + "c8c14507b2d37395": { + "source": "推理强度", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 109, + "column": 22 + } + ] + }, + "c98e118e0a43f078": { + "source": "模型", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 980, + "column": 28 + }, + { + "file": "src/state/appState.js", + "line": 982, + "column": 38 + }, + { + "file": "src/state/appState.js", + "line": 1017, + "column": 93 + } + ] + }, + "ca00a39fcea70dc6": { + "source": "启动中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 800, + "column": 51 + } + ] + }, + "ca1d1059408b3837": { + "source": "异常轮次:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 79, + "column": 5 + } + ] + }, + "ce46f23cea3bf3c5": { + "source": "开启后,Cursor将直接接通官方,请勿开启", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/DirectModeSwitch.vue", + "line": 9, + "column": 14 + } + ] + }, + "d0325067fed88e5a": { + "source": "缓存命中率 {0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/charts/CacheHitRateChart.vue", + "line": 6, + "column": 21 + } + ] + }, + "d08fd4224abcd69d": { + "source": "切换失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Home.vue", + "line": 72, + "column": 27 + } + ] + }, + "d1bde4a4e057b2c7": { + "source": "[MainLayout] 加载作者信息失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 60, + "column": 19 + } + ] + }, + "d20ab96566d33f25": { + "source": "{0} 的显示名称不能为空", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 297, + "column": 14 + } + ] + }, + "d2243e1d44b2a94e": { + "source": "编辑模型配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 79, + "column": 56 + } + ] + }, + "d3209b935ae86797": { + "source": "模型配置不存在,无法删除", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 961, + "column": 14 + }, + { + "file": "src/views/ModelConfig.vue", + "line": 112, + "column": 35 + } + ] + }, + "d373809ab86ba93b": { + "source": "拷贝", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/Tooltip.vue", + "line": 31, + "column": 23 + } + ] + }, + "d3b1da3088ddd334": { + "source": "模型测试失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 196, + "column": 38 + }, + { + "file": "src/state/appState.js", + "line": 226, + "column": 50 + } + ] + }, + "d7da2aabd35772ec": { + "source": "例如:200000(留空用默认值)", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 101, + "column": 16 + } + ] + }, + "da590a8fe3ce4de0": { + "source": "请选择", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/Select.vue", + "line": 11, + "column": 41 + } + ] + }, + "daede9881787abe7": { + "source": "备注", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 247, + "column": 18 + }, + { + "file": "src/views/ModelEditor.vue", + "line": 168, + "column": 20 + } + ] + }, + "dbee6e7139243362": { + "source": "{0} 的接口地址不能为空", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 303, + "column": 14 + } + ] + }, + "dc82c5e8fb2ab777": { + "source": "版本:v{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 828, + "column": 11 + } + ] + }, + "de8184da1ef88d03": { + "source": "已配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 51, + "column": 48 + } + ] + }, + "e14c41ef2b7253c9": { + "source": "总请求:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 88, + "column": 5 + } + ] + }, + "e406825e0a72d2c2": { + "source": "本地配置", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/Config.vue", + "line": 6, + "column": 57 + }, + { + "file": "src/views/Home.vue", + "line": 44, + "column": 57 + } + ] + }, + "e552c2accdbf5178": { + "source": "新增模型", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelConfig.vue", + "line": 28, + "column": 110 + } + ] + }, + "e6faccfddce722e8": { + "source": "缓存读取:{0}", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/components/HomeMetricsCard.vue", + "line": 65, + "column": 5 + }, + { + "file": "src/components/HomeMetricsCard.vue", + "line": 91, + "column": 5 + } + ] + }, + "eaffd48cd2ea9f1a": { + "source": "例如:https://api.anthropic.com", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 60, + "column": 32 + } + ] + }, + "ec3b17a75db49e24": { + "source": "{0} t/s | 首字 {1}", + "kind": "template", + "placeholders": 2, + "refs": [ + { + "file": "src/state/appState.js", + "line": 202, + "column": 10 + } + ] + }, + "ec99e5c45d648fd6": { + "source": "更新失败", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 819, + "column": 16 + } + ] + }, + "f1e0fc261d42fe29": { + "source": "模型列表 hover 时显示的备注说明。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 91, + "column": 16 + } + ] + }, + "f363622480699c52": { + "source": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/views/ModelEditor.vue", + "line": 87, + "column": 20 + } + ] + }, + "f3a76d896853c1df": { + "source": "未命中", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/charts/CacheHitRateChart.vue", + "line": 76, + "column": 18 + } + ] + }, + "f474a4108aba4c4c": { + "source": "关闭服务", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 802, + "column": 38 + } + ] + }, + "f56c6c82203b33f6": { + "source": "提示", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/InputModal.vue", + "line": 6, + "column": 35 + }, + { + "file": "src/components/ui/Modal.vue", + "line": 6, + "column": 35 + }, + { + "file": "src/composables/useInputModal.js", + "line": 5, + "column": 10 + }, + { + "file": "src/composables/useInputModal.js", + "line": 20, + "column": 46 + }, + { + "file": "src/composables/useModal.js", + "line": 5, + "column": 10 + }, + { + "file": "src/composables/useModal.js", + "line": 22, + "column": 41 + }, + { + "file": "src/views/Config.vue", + "line": 33, + "column": 12 + } + ] + }, + "f61e03f047b786d5": { + "source": "{0} 的最大输出 Token 必须为正整数", + "kind": "template", + "placeholders": 1, + "refs": [ + { + "file": "src/state/appState.js", + "line": 321, + "column": 14 + }, + { + "file": "src/state/appState.js", + "line": 324, + "column": 14 + } + ] + }, + "fac2a67ad87807c4": { + "source": "确定", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/components/ui/InputModal.vue", + "line": 33, + "column": 67 + }, + { + "file": "src/components/ui/Modal.vue", + "line": 8, + "column": 41 + }, + { + "file": "src/composables/useModal.js", + "line": 7, + "column": 16 + }, + { + "file": "src/composables/useModal.js", + "line": 24, + "column": 53 + }, + { + "file": "src/layouts/MainLayout.vue", + "line": 68, + "column": 18 + }, + { + "file": "src/state/appState.js", + "line": 840, + "column": 56 + } + ] + }, + "fb7a4c81729ed0ca": { + "source": "关闭中...", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/state/appState.js", + "line": 800, + "column": 40 + } + ] + }, + "fec45092945f8790": { + "source": "使用教程", + "kind": "text", + "placeholders": 0, + "refs": [ + { + "file": "src/layouts/MainLayout.vue", + "line": 57, + "column": 16 + } + ] + } + } +} diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json new file mode 100644 index 0000000..b305d46 --- /dev/null +++ b/frontend/src/i18n/locales/en-US.json @@ -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" +} diff --git a/frontend/src/i18n/locales/ja-JP.json b/frontend/src/i18n/locales/ja-JP.json new file mode 100644 index 0000000..ce7bc07 --- /dev/null +++ b/frontend/src/i18n/locales/ja-JP.json @@ -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": "ユーザーガイド" +} diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..98ed1c5 --- /dev/null +++ b/frontend/src/i18n/locales/zh-CN.json @@ -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": "使用教程" +} diff --git a/frontend/src/i18n/runtime.js b/frontend/src/i18n/runtime.js new file mode 100644 index 0000000..412ebcd --- /dev/null +++ b/frontend/src/i18n/runtime.js @@ -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; +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..8e65e5d --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,156 @@ + + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..c8ff048 --- /dev/null +++ b/frontend/src/main.js @@ -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(() => { + // 启动阶段失败时保持界面可用,错误在业务交互中再提示。 +}); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..875230b --- /dev/null +++ b/frontend/src/router/index.js @@ -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; diff --git a/frontend/src/services/clientApi.js b/frontend/src/services/clientApi.js new file mode 100644 index 0000000..74dc7d9 --- /dev/null +++ b/frontend/src/services/clientApi.js @@ -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`), + ); +} diff --git a/frontend/src/state/appState.js b/frontend/src/state/appState.js new file mode 100644 index 0000000..74d39d0 --- /dev/null +++ b/frontend/src/state/appState.js @@ -0,0 +1,1350 @@ +import { computed, reactive, watchSyncEffect } from "vue"; +import { Events } from "@wailsio/runtime"; +import dayjs from "dayjs"; +import { + getAppVersion, + getHomeMetricsSummary, + getModelAdapterTestResults, + getProxyState, + listRemoteModels, + openConfigWindow as openConfig, + loadUserConfig, + openLogsDirectory, + openModelConfig, + openModelEditor, + saveUserConfig, + startProxyService, + stopProxyService, + testModelAdapter, +} from "@/services/clientApi"; + +const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2"; +const GENERIC_SERVICE_ERROR = "服务错误"; +const SUPPORTED_MODEL_ADAPTER_TYPES = new Set(["openai", "anthropic"]); +const SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); +const SUPPORTED_ANTHROPIC_THINKING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]); +export const ANTHROPIC_THINKING_EFFORT_DEFAULT = "xhigh"; +export const OPENAI_ENDPOINT_RESPONSES = "/v1/responses"; +export const OPENAI_ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions"; +export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{ + "service_tier": "priority" +}`; +export const EXTRA_PARAMS_DEFAULT_JSON = `{ +}`; +export const CUSTOM_HEADERS_DEFAULT_JSON = `{ +}`; +const SUPPORTED_OPENAI_ENDPOINTS = new Set([OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_CHAT_COMPLETIONS]); +const SUPPORTED_ROUTE_MODES = new Set(["local", "upstream"]); +const PROXY_STATE_EVENT = "proxy:state"; +const USER_CONFIG_CHANGED_EVENT = "user-config:changed"; +const MODEL_ADAPTER_TEST_UPDATED_EVENT = "model-adapter-test:updated"; +const SUPPORTED_MODEL_ADAPTER_TEST_STATUSES = new Set(["idle", "running", "success", "error"]); +const HOME_METRICS_MIN_LOADING_MS = 600; + +export const ROUTE_MODE_OPTIONS = [ + { label: "本地服务模式", value: "local" }, + { label: "直连 Cursor 模式", value: "upstream" }, +]; + +function asString(value) { + if (typeof value === "string") { + return value.trim(); + } + if (value instanceof String) { + return value.toString().trim(); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +function asBoolean(value, fallback = false) { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + const normalized = asString(value).toLowerCase(); + if (!normalized) { + return fallback; + } + return normalized === "true" || normalized === "1" || normalized === "yes"; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function asPositiveIntegerString(value) { + const text = asString(value); + if (!text) { + return ""; + } + if (!/^\d+$/.test(text)) { + return ""; + } + return Number(text) > 0 ? text : ""; +} + +function asPositiveInteger(value) { + const text = asPositiveIntegerString(value); + if (!text) { + return 0; + } + return Number(text); +} + +const HOME_METRICS_REFRESH_MIN_SECONDS = 5; +const HOME_METRICS_REFRESH_DEFAULT_SECONDS = 60; + +function normalizeRefreshIntervalSeconds(value) { + const parsed = asPositiveInteger(value); + if (parsed < HOME_METRICS_REFRESH_MIN_SECONDS) { + return HOME_METRICS_REFRESH_DEFAULT_SECONDS; + } + return parsed; +} + +// ── 首页统计定时刷新 ── +let homeMetricsRefreshTimer = null; + +function scheduleHomeMetricsAutoRefresh() { + if (homeMetricsRefreshTimer !== null) { + window.clearInterval(homeMetricsRefreshTimer); + homeMetricsRefreshTimer = null; + } + const intervalSeconds = normalizeRefreshIntervalSeconds(appState.homeMetricsRefreshInterval); + homeMetricsRefreshTimer = window.setInterval(() => { + if (appState.homeMetricsLoading) { + return; + } + syncHomeMetrics().catch(() => {}); + }, intervalSeconds * 1000); +} + +function asNumber(value, fallback = 0) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + const text = asString(value); + if (!text) { + return fallback; + } + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function formatReleaseDate(value) { + const text = asString(value); + if (!text) { + return "未知"; + } + const parsed = dayjs(text); + if (!parsed.isValid()) { + return text; + } + return parsed.format("YYYY-MM-DD HH:mm"); +} + +function normalizeRouteMode(value, fallback = "local") { + const text = asString(value).toLowerCase(); + if (SUPPORTED_ROUTE_MODES.has(text)) { + return text; + } + return fallback; +} + +function normalizeBaseURL(value) { + const text = asString(value); + if (!text) { + return ""; + } + try { + const parsed = new URL(text); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return ""; + } + parsed.protocol = parsed.protocol.toLowerCase(); + parsed.hostname = parsed.hostname.toLowerCase(); + const normalized = parsed.toString().replace(/\/+$/, ""); + return normalized || parsed.toString(); + } catch (_error) { + return text; + } +} + +function buildModelAdapterIdentityKey(adapter) { + return [ + normalizeBaseURL(adapter.baseURL), + asString(adapter.modelID), + asString(adapter.apiKey), + asString(adapter.displayName), + adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "", + ].join("\n"); +} + +function hashStringFNV32a(value) { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +export function buildModelAdapterTestRequestHash(source) { + const adapter = normalizeModelAdapter(source); + return hashStringFNV32a([ + asString(adapter.type), + normalizeBaseURL(adapter.baseURL), + asString(adapter.apiKey), + asString(adapter.modelID), + adapter.type === "openai" ? asString(adapter.reasoningEffort || "medium") : "", + adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "", + adapter.type === "openai" ? String(Boolean(adapter.openAIExtraParamsEnabled)) : "false", + adapter.type === "openai" && adapter.openAIExtraParamsEnabled ? asString(adapter.openAIExtraParamsJSON) : "", + String(Boolean(adapter.customHeadersEnabled)), + adapter.customHeadersEnabled ? asString(adapter.customHeadersJSON) : "", + adapter.type === "anthropic" ? String(Boolean(adapter.anthropicExtraParamsEnabled)) : "false", + adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled ? asString(adapter.anthropicExtraParamsJSON) : "", + String(asPositiveInteger(adapter.contextWindowTokens)), + String(asPositiveInteger(adapter.maxCompletionTokens)), + String(asPositiveInteger(adapter.anthropicMaxTokens)), + adapter.type === "anthropic" ? asString(adapter.anthropicThinkingEffort || ANTHROPIC_THINKING_EFFORT_DEFAULT) : "", + ].join("\n")); +} + +export function formatDuration(value) { + const durationMS = Math.max(0, Math.round(asNumber(value))); + if (durationMS < 1000) { + return `${durationMS} ms`; + } + return `${(durationMS / 1000).toFixed(1)} s`; +} + +function normalizeModelAdapterTestStatus(value) { + const text = asString(value).toLowerCase(); + return SUPPORTED_MODEL_ADAPTER_TEST_STATUSES.has(text) ? text : "idle"; +} + +export function formatModelAdapterTestSummary(source) { + const result = source && typeof source === "object" ? source : {}; + const status = normalizeModelAdapterTestStatus(result.status); + if (status === "running") { + return "测试中..."; + } + if (status === "error") { + return asString(result.error) || "模型测试失败"; + } + if (status !== "success") { + return ""; + } + const roundedTPS = Math.max(0, Math.round(asNumber(result.tokensPerSecond))); + return `${roundedTPS} t/s | 首字 ${formatDuration(result.firstTextTokenMS)}`; +} + +function normalizeModelAdapterTestResult(source) { + const raw = source && typeof source === "object" ? source : {}; + const status = normalizeModelAdapterTestStatus(raw.status); + const normalized = { + adapterID: asString(raw.adapterID), + requestHash: asString(raw.requestHash), + status, + tokensPerSecond: Math.max(0, asNumber(raw.tokensPerSecond)), + firstTextTokenMS: Math.max(0, Math.round(asNumber(raw.firstTextTokenMS))), + totalDurationMS: Math.max(0, Math.round(asNumber(raw.totalDurationMS))), + outputTokens: Math.max(0, Math.round(asNumber(raw.outputTokens))), + tokensEstimated: asBoolean(raw.tokensEstimated), + summaryText: asString(raw.summaryText), + error: asString(raw.error), + rawResponse: asString(raw.rawResponse), + testedAt: asString(raw.testedAt), + }; + if (!normalized.summaryText) { + normalized.summaryText = formatModelAdapterTestSummary(normalized); + } + if (status === "error" && !normalized.summaryText) { + normalized.summaryText = normalized.error || "模型测试失败"; + } + return normalized; +} + +function normalizeModelAdapterTestResults(source) { + const raw = source && typeof source === "object" && !Array.isArray(source) + ? source.results + : source; + return asArray(raw) + .map((item) => normalizeModelAdapterTestResult(item)) + .filter((item) => item.adapterID); +} + +export function createEmptyModelAdapter() { + return { + id: "", + displayName: "", + type: "openai", + baseURL: "", + apiKey: "", + tooltipData: "备注", + modelID: "", + reasoningEffort: "medium", + openAIEndpoint: OPENAI_ENDPOINT_RESPONSES, + openAIExtraParamsEnabled: false, + openAIExtraParamsJSON: OPENAI_EXTRA_PARAMS_DEFAULT_JSON, + customHeadersEnabled: false, + customHeadersJSON: CUSTOM_HEADERS_DEFAULT_JSON, + anthropicExtraParamsEnabled: false, + anthropicExtraParamsJSON: EXTRA_PARAMS_DEFAULT_JSON, + contextWindowTokens: 0, + maxCompletionTokens: 0, + anthropicMaxTokens: 0, + anthropicThinkingEffort: ANTHROPIC_THINKING_EFFORT_DEFAULT, + thinkingBudgetTokens: 0, + }; +} + +function normalizeOpenAIEndpoint(value) { + const text = asString(value).toLowerCase(); + if (!text) { + return OPENAI_ENDPOINT_RESPONSES; + } + return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : ""; +} + +function validateJSONObject(value, label) { + const text = asString(value); + if (!text) { + return `${label}不能为空`; + } + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return `${label}必须是 JSON 对象`; + } + } catch (_error) { + return `${label}必须是合法 JSON 对象`; + } + return ""; +} + +function validateHeadersJSON(value) { + const objectError = validateJSONObject(value, "自定义请求头 JSON"); + if (objectError) { + return objectError; + } + const parsed = JSON.parse(asString(value)); + for (const [key, item] of Object.entries(parsed)) { + if (!asString(key)) { + return "自定义请求头名称不能为空"; + } + if (typeof item !== "string") { + return `自定义请求头 ${key} 的值必须是字符串`; + } + } + return ""; +} + +function validateOpenAIExtraParamsJSON(value) { + return validateJSONObject(value, "额外参数 JSON"); +} + +function validateAnthropicExtraParamsJSON(value) { + return validateJSONObject(value, "Anthropic 额外参数 JSON"); +} + +export function normalizeModelAdapter(source) { + const raw = source && typeof source === "object" ? source : {}; + const normalizedType = asString(raw.type).toLowerCase(); + const normalizedReasoningEffort = asString(raw.reasoningEffort || raw.reasoning_effort).toLowerCase(); + const normalizedAnthropicThinkingEffort = asString( + raw.anthropicThinkingEffort + ?? raw.anthropic_thinking_effort + ?? raw.outputConfigEffort + ?? raw.output_config_effort, + ).toLowerCase(); + const normalizedOpenAIEndpoint = normalizeOpenAIEndpoint( + raw.openAIEndpoint ?? raw.openaiEndpoint ?? raw.open_ai_endpoint ?? raw.endpoint, + ); + const openAIExtraParamsEnabled = normalizedType === "openai" + ? asBoolean(raw.openAIExtraParamsEnabled ?? raw.openaiExtraParamsEnabled ?? raw.open_ai_extra_params_enabled) + : false; + const openAIExtraParamsJSON = normalizedType === "openai" + ? asString(raw.openAIExtraParamsJSON ?? raw.openaiExtraParamsJSON ?? raw.open_ai_extra_params_json) || OPENAI_EXTRA_PARAMS_DEFAULT_JSON + : ""; + const customHeadersEnabled = asBoolean(raw.customHeadersEnabled ?? raw.custom_headers_enabled); + const customHeadersJSON = asString(raw.customHeadersJSON ?? raw.custom_headers_json) || CUSTOM_HEADERS_DEFAULT_JSON; + const anthropicExtraParamsEnabled = normalizedType === "anthropic" + ? asBoolean(raw.anthropicExtraParamsEnabled ?? raw.anthropic_extra_params_enabled) + : false; + const anthropicExtraParamsJSON = normalizedType === "anthropic" + ? asString(raw.anthropicExtraParamsJSON ?? raw.anthropic_extra_params_json) || EXTRA_PARAMS_DEFAULT_JSON + : ""; + return { + id: asString(raw.id), + displayName: asString(raw.displayName || raw.name), + type: SUPPORTED_MODEL_ADAPTER_TYPES.has(normalizedType) ? normalizedType : "", + baseURL: normalizeBaseURL(raw.baseURL || raw.url), + apiKey: asString(raw.apiKey || raw.key), + tooltipData: asString(raw.tooltipData), + modelID: asString(raw.modelID), + reasoningEffort: SUPPORTED_REASONING_EFFORTS.has(normalizedReasoningEffort) + ? normalizedReasoningEffort + : "medium", + openAIEndpoint: normalizedType === "openai" ? normalizedOpenAIEndpoint : "", + openAIExtraParamsEnabled, + openAIExtraParamsJSON, + customHeadersEnabled, + customHeadersJSON, + anthropicExtraParamsEnabled, + anthropicExtraParamsJSON, + contextWindowTokens: asPositiveInteger( + raw.contextWindowTokens ?? raw.context_window_tokens ?? raw.maxInputTokens ?? raw.max_input_tokens, + ), + maxCompletionTokens: asPositiveInteger( + raw.maxCompletionTokens ?? raw.max_completion_tokens ?? raw.max_tokens ?? raw.max_token, + ), + anthropicMaxTokens: asPositiveInteger( + raw.anthropicMaxTokens ?? raw.anthropic_max_tokens ?? raw.max_tokens, + ), + anthropicThinkingEffort: normalizedType === "anthropic" + ? (SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(normalizedAnthropicThinkingEffort) + ? normalizedAnthropicThinkingEffort + : ANTHROPIC_THINKING_EFFORT_DEFAULT) + : "", + thinkingBudgetTokens: asPositiveInteger( + raw.thinkingBudgetTokens ?? raw.thinking_budget_tokens, + ), + }; +} + +export function normalizeModelAdapters(source) { + return asArray(source).map((item) => normalizeModelAdapter(item)); +} + +export function validateModelAdapters(source) { + const adapters = normalizeModelAdapters(source); + const seenIdentityKeys = new Set(); + for (const [index, adapter] of adapters.entries()) { + const prefix = `模型 ${index + 1}`; + if (!adapter.displayName) { + return `${prefix} 的显示名称不能为空`; + } + if (!SUPPORTED_MODEL_ADAPTER_TYPES.has(adapter.type)) { + return `${prefix} 的类型仅支持 OpenAI 或 Anthropic`; + } + if (!adapter.baseURL) { + return `${prefix} 的接口地址不能为空`; + } + if (!adapter.apiKey) { + return `${prefix} 的访问密钥不能为空`; + } + if (!adapter.tooltipData) { + return `${prefix} 的悬停提示不能为空`; + } + if (!adapter.modelID) { + return `${prefix} 的模型标识不能为空`; + } + if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) { + return `${prefix} 的推理强度仅支持 low、medium、high、xhigh`; + } + if (adapter.type === "openai" && !SUPPORTED_OPENAI_ENDPOINTS.has(adapter.openAIEndpoint)) { + return `${prefix} 的 OpenAI 端点仅支持 /v1/responses 或 /v1/chat/completions`; + } + if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) { + const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON); + if (extraParamsError) { + return `${prefix} 的 ${extraParamsError}`; + } + } + if (adapter.customHeadersEnabled) { + const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON); + if (customHeadersError) { + return `${prefix} 的 ${customHeadersError}`; + } + } + if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) { + const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON); + if (extraParamsError) { + return `${prefix} 的 ${extraParamsError}`; + } + } + if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) { + return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`; + } + if (adapter.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) { + return `${prefix} 的上下文窗口必须为正整数`; + } + if (adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) { + return `${prefix} 的最大输出 Token 必须为正整数`; + } + if (adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) { + return `${prefix} 的最大输出 Token 必须为正整数`; + } + if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) { + return `${prefix} 的思考预算 Token 必须为正整数`; + } + const dedupeKey = buildModelAdapterIdentityKey(adapter); + if (seenIdentityKeys.has(dedupeKey)) { + return `模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合`; + } + seenIdentityKeys.add(dedupeKey); + } + return ""; +} + +function validateConfigPayload(payload) { + if (!SUPPORTED_ROUTE_MODES.has(normalizeRouteMode(payload?.routing?.mode, ""))) { + return "运行模式仅支持 local 或 upstream"; + } + return ""; +} + +function canUseLocalStorage() { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function delay(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, Math.max(0, ms)); + }); +} + +function createEmptyHomeMetrics() { + return { + turnsTotal: 0, + validTurnsTotal: 0, + invalidTurnsTotal: 0, + requestTokensTotal: 0, + promptTokensTotal: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheHitRate: null, + }; +} + +// ── session-mode baseline (in-memory, per app run) ── +// 后端始终累积写入 usage.json,baseline 仅用于增量计算的起点快照 +let homeMetricsBaseline = null; +let homeMetricsRaw = createEmptyHomeMetrics(); + +// 与后端 cacheHitRateFromTotals 同口径:分子 = 缓存读取,分母 = 缓存读取 + 非缓存输入(+ 缓存写入,按配置)。 +function computeIncrementalCacheHitRate(promptTokensTotal, cacheReadTokens, cacheWriteTokens) { + const inputTokens = Math.max(0, promptTokensTotal - cacheReadTokens - cacheWriteTokens); + let denominator = cacheReadTokens + inputTokens; + if (appState.includeCacheWriteInHitRate) { + denominator += cacheWriteTokens; + } + if (denominator <= 0) { + return null; + } + return cacheReadTokens / denominator; +} + +function computeDisplayMetrics() { + // 持久化模式:直接展示后端累积值 + if (appState.homeMetricsPersistent) { + return { ...homeMetricsRaw }; + } + // 运行时模式:展示本次运行以来的增量;baseline 未就绪时回退到 raw + if (!homeMetricsBaseline) { + return { ...homeMetricsRaw }; + } + const cacheReadTokens = Math.max(0, homeMetricsRaw.cacheReadTokens - homeMetricsBaseline.cacheReadTokens); + const cacheWriteTokens = Math.max(0, homeMetricsRaw.cacheWriteTokens - homeMetricsBaseline.cacheWriteTokens); + const promptTokensTotal = Math.max(0, homeMetricsRaw.promptTokensTotal - homeMetricsBaseline.promptTokensTotal); + return { + turnsTotal: Math.max(0, homeMetricsRaw.turnsTotal - homeMetricsBaseline.turnsTotal), + validTurnsTotal: Math.max(0, homeMetricsRaw.validTurnsTotal - homeMetricsBaseline.validTurnsTotal), + invalidTurnsTotal: Math.max(0, homeMetricsRaw.invalidTurnsTotal - homeMetricsBaseline.invalidTurnsTotal), + requestTokensTotal: Math.max(0, homeMetricsRaw.requestTokensTotal - homeMetricsBaseline.requestTokensTotal), + promptTokensTotal, + cacheReadTokens, + cacheWriteTokens, + cacheHitRate: computeIncrementalCacheHitRate(promptTokensTotal, cacheReadTokens, cacheWriteTokens), + }; +} + +function loadCachedState() { + if (!canUseLocalStorage()) { + return {}; + } + + try { + const raw = window.localStorage.getItem(APP_STATE_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed; + } catch (_error) { + return {}; + } +} + +function normalizeConfig(source) { + const raw = source && typeof source === "object" ? source : {}; + const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {}; + const homeMetrics = raw.homeMetrics && typeof raw.homeMetrics === "object" ? raw.homeMetrics : {}; + return { + log: asBoolean(raw.log), + providerStreamIdleTimeout: asPositiveInteger(raw.providerStreamIdleTimeout), + backendListenAddr: asString(raw.configBackendListenAddr) || asString(raw.backendListenAddr), + proxyListenAddr: asString(raw.configProxyListenAddr) || asString(raw.proxyListenAddr), + modelAdapters: normalizeModelAdapters(raw.modelAdapters), + routing: { + mode: normalizeRouteMode(routing.mode), + }, + homeMetrics: { + includeCacheWriteInHitRate: asBoolean( + homeMetrics.includeCacheWriteInHitRate ?? raw.includeCacheWriteInHitRate, + ), + persistentMode: asBoolean( + homeMetrics.persistentMode ?? raw.homeMetricsPersistent, + true, + ), + refreshIntervalSeconds: normalizeRefreshIntervalSeconds(homeMetrics.refreshIntervalSeconds), + }, + lastAgentModelHash: asString(raw.lastAgentModelHash), + tabServerBaseURL: asString(raw.tabServerBaseURL), + tabServerToken: asString(raw.tabServerToken), + }; +} + +function asNullableRate(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return null; +} + +function normalizeHomeMetrics(source) { + const raw = source && typeof source === "object" ? source : {}; + return { + turnsTotal: asPositiveInteger(raw.turnsTotal), + validTurnsTotal: asPositiveInteger(raw.validTurnsTotal), + invalidTurnsTotal: asPositiveInteger(raw.invalidTurnsTotal), + requestTokensTotal: asPositiveInteger(raw.requestTokensTotal), + promptTokensTotal: asPositiveInteger(raw.promptTokensTotal), + cacheReadTokens: asPositiveInteger(raw.cacheReadTokens), + cacheWriteTokens: asPositiveInteger(raw.cacheWriteTokens), + cacheHitRate: asNullableRate(raw.cacheHitRate), + }; +} + +function applyHomeMetrics(raw) { + homeMetricsRaw = normalizeHomeMetrics(raw); + // 首次拉取时捕获 baseline(仅内存,不持久化) + if (!homeMetricsBaseline) { + homeMetricsBaseline = { ...homeMetricsRaw }; + } + appState.homeMetrics = computeDisplayMetrics(); + appState.homeMetricsError = ""; +} + +function buildConfigPayload(source = appState) { + const normalized = normalizeConfig(source); + return { + log: normalized.log, + providerStreamIdleTimeout: normalized.providerStreamIdleTimeout, + backendListenAddr: normalized.backendListenAddr, + proxyListenAddr: normalized.proxyListenAddr, + modelAdapters: normalized.modelAdapters.map(({ id, ...adapter }) => adapter), + routing: normalized.routing, + homeMetrics: normalized.homeMetrics, + lastAgentModelHash: normalized.lastAgentModelHash, + tabServerBaseURL: normalized.tabServerBaseURL, + tabServerToken: normalized.tabServerToken, + }; +} + +// sanitizePayloadForCache 剥离敏感字段(apiKey/tabServerToken),避免 localStorage 明文落盘。 +// 缓存仅用于启动瞬间兜底展示,真实配置始终以后端返回为准。 +function sanitizePayloadForCache(payload) { + return { + ...payload, + modelAdapters: (payload.modelAdapters || []).map(({ apiKey, ...adapter }) => ({ + ...adapter, + apiKey: "", + })), + tabServerToken: "", + }; +} + +function applyConfigToState(config, { modelAdaptersOnly = false } = {}) { + const normalized = normalizeConfig(config); + if (modelAdaptersOnly) { + appState.modelAdapters = normalized.modelAdapters; + return normalized; + } + appState.modelAdapters = normalized.modelAdapters; + appState.configBackendListenAddr = normalized.backendListenAddr; + appState.configProxyListenAddr = normalized.proxyListenAddr; + appState.routingMode = normalized.routing.mode; + appState.includeCacheWriteInHitRate = normalized.homeMetrics.includeCacheWriteInHitRate; + appState.homeMetricsPersistent = normalized.homeMetrics.persistentMode; + appState.homeMetricsRefreshInterval = normalized.homeMetrics.refreshIntervalSeconds; + scheduleHomeMetricsAutoRefresh(); + appState.tabServerBaseURL = normalized.tabServerBaseURL; + appState.tabServerToken = normalized.tabServerToken; + return normalized; +} + +async function loadPersistedUserConfig() { + return normalizeConfig(await loadUserConfig()); +} + +async function persistConfigPayload(config, { modelAdaptersOnly = false } = {}) { + const payload = buildConfigPayload(config); + const configValidationError = validateConfigPayload(payload); + if (configValidationError) { + return { + ok: false, + error: configValidationError, + }; + } + const validationError = validateModelAdapters(payload.modelAdapters); + if (validationError) { + return { + ok: false, + error: validationError, + }; + } + + appState.configSaving = true; + try { + await saveUserConfig(payload); + const persisted = await loadPersistedUserConfig(); + applyConfigToState(persisted, { modelAdaptersOnly }); + return { + ok: true, + error: "", + }; + } catch (error) { + return { + ok: false, + error: toUserError(error), + }; + } finally { + appState.configSaving = false; + } +} + +function applyProxyState(raw) { + const state = raw && typeof raw === "object" ? raw : {}; + appState.backendRunning = asBoolean(state.backendRunning); + appState.proxyRunning = asBoolean(state.proxyRunning ?? state.running); + appState.serviceRunning = appState.proxyRunning; + appState.serviceLastError = asString(state.lastError); + appState.backendListenAddr = asString(state.backendListenAddr); + appState.proxyListenAddr = asString(state.proxyListenAddr || state.listenAddr); + appState.serviceListenAddr = appState.proxyListenAddr; + appState.cursorSettingsApplied = asBoolean(state.cursorSettingsApplied); + appState.netProxySource = asString(state.netProxySource); + appState.netProxyActive = asBoolean(state.netProxyActive); + appState.netProxyUsingSystem = asBoolean(state.netProxyUsingSystem); + appState.netProxyUsingEnv = asBoolean(state.netProxyUsingEnv); + appState.netProxyHttp = asString(state.netProxyHttp); + appState.netProxyHttps = asString(state.netProxyHttps); + appState.netProxyPacIgnored = asBoolean(state.netProxyPacIgnored); + appState.netProxyDescription = asString(state.netProxyDescription); +} + +function handleProxyStateEvent(event) { + if (event?.data && typeof event.data === "object") { + applyProxyState(event.data); + return; + } + void syncServiceState().catch(() => {}); +} + +function handleUserConfigChangedEvent(event) { + if (event?.data && typeof event.data === "object") { + applyConfigToState(event.data); + return; + } + void reloadUserConfig().catch(() => {}); +} + +function applyModelAdapterTestResults(source) { + const next = {}; + for (const result of normalizeModelAdapterTestResults(source)) { + next[result.adapterID] = result; + } + appState.modelAdapterTestResults = next; + return next; +} + +function handleModelAdapterTestUpdatedEvent(event) { + if (event?.data) { + applyModelAdapterTestResults(event.data); + return; + } + void refreshModelAdapterTestResults().catch(() => {}); +} + +function extractErrorMessage(error) { + if (typeof error === "string") { + return error.trim(); + } + if (error && typeof error === "object") { + return asString(error.message) || asString(error.error); + } + return ""; +} + +const cachedState = loadCachedState(); +const cachedConfig = normalizeConfig(cachedState); + +export const appState = reactive({ + appVersion: "", + modelAdapters: cachedConfig.modelAdapters, + modelAdapterTestResults: {}, + configBackendListenAddr: cachedConfig.backendListenAddr, + configProxyListenAddr: cachedConfig.proxyListenAddr, + routingMode: cachedConfig.routing.mode, + includeCacheWriteInHitRate: cachedConfig.homeMetrics.includeCacheWriteInHitRate, + homeMetricsPersistent: cachedConfig.homeMetrics.persistentMode, + homeMetricsRefreshInterval: cachedConfig.homeMetrics.refreshIntervalSeconds || 60, + tabServerBaseURL: cachedConfig.tabServerBaseURL, + tabServerToken: cachedConfig.tabServerToken, + + serviceRunning: asBoolean(cachedState.serviceRunning), + backendRunning: asBoolean(cachedState.backendRunning), + proxyRunning: asBoolean(cachedState.proxyRunning), + serviceBusy: false, + serviceLastError: asString(cachedState.serviceLastError), + serviceListenAddr: asString(cachedState.serviceListenAddr), + // 监听地址是运行时状态,不从缓存恢复(旧缓存曾混入配置值),由 syncServiceState 刷新。 + backendListenAddr: "", + proxyListenAddr: "", + cursorSettingsApplied: asBoolean(cachedState.cursorSettingsApplied), + netProxySource: asString(cachedState.netProxySource), + netProxyActive: asBoolean(cachedState.netProxyActive), + netProxyUsingSystem: asBoolean(cachedState.netProxyUsingSystem), + netProxyUsingEnv: asBoolean(cachedState.netProxyUsingEnv), + netProxyHttp: asString(cachedState.netProxyHttp), + netProxyHttps: asString(cachedState.netProxyHttps), + netProxyPacIgnored: asBoolean(cachedState.netProxyPacIgnored), + netProxyDescription: asString(cachedState.netProxyDescription), + + configSaving: false, + homeMetrics: createEmptyHomeMetrics(), + homeMetricsLoading: false, + homeMetricsError: "", +}); + +watchSyncEffect(() => { + if (!canUseLocalStorage()) { + return; + } + try { + window.localStorage.setItem( + APP_STATE_STORAGE_KEY, + JSON.stringify({ + ...sanitizePayloadForCache(buildConfigPayload()), + serviceRunning: appState.serviceRunning, + backendRunning: appState.backendRunning, + proxyRunning: appState.proxyRunning, + serviceLastError: appState.serviceLastError, + serviceListenAddr: appState.serviceListenAddr, + configBackendListenAddr: appState.configBackendListenAddr, + configProxyListenAddr: appState.configProxyListenAddr, + backendListenAddr: appState.backendListenAddr, + proxyListenAddr: appState.proxyListenAddr, + tabServerBaseURL: appState.tabServerBaseURL, + tabServerToken: "", + cursorSettingsApplied: appState.cursorSettingsApplied, + netProxySource: appState.netProxySource, + netProxyActive: appState.netProxyActive, + netProxyUsingSystem: appState.netProxyUsingSystem, + netProxyUsingEnv: appState.netProxyUsingEnv, + netProxyHttp: appState.netProxyHttp, + netProxyHttps: appState.netProxyHttps, + netProxyPacIgnored: appState.netProxyPacIgnored, + netProxyDescription: appState.netProxyDescription, + }), + ); + } catch (_error) { + // ignore local persistence failures + } +}); + +watchSyncEffect((onCleanup) => { + if (typeof window === "undefined") { + return; + } + const unsubscribe = Events.On(PROXY_STATE_EVENT, handleProxyStateEvent); + onCleanup(() => { + unsubscribe(); + }); +}); + +watchSyncEffect((onCleanup) => { + if (typeof window === "undefined") { + return; + } + const unsubscribe = Events.On(USER_CONFIG_CHANGED_EVENT, handleUserConfigChangedEvent); + onCleanup(() => { + unsubscribe(); + }); +}); + +watchSyncEffect((onCleanup) => { + if (typeof window === "undefined") { + return; + } + const unsubscribe = Events.On(MODEL_ADAPTER_TEST_UPDATED_EVENT, handleModelAdapterTestUpdatedEvent); + onCleanup(() => { + unsubscribe(); + }); +}); + +export const appViewState = reactive({ + serviceStatusText: computed(() => { + if (appState.proxyRunning && appState.backendRunning) { + return "服务运行中"; + } + if (appState.backendRunning) { + return "后端已启动,代理未启动"; + } + return "服务未启动"; + }), + serviceStatusClass: computed(() => + appState.serviceRunning ? "text-[#22c55e]" : "text-[#f59e0b]", + ), + serviceButtonText: computed(() => { + if (appState.serviceBusy) { + return appState.serviceRunning ? "关闭中..." : "启动中..."; + } + return appState.serviceRunning ? "关闭服务" : "启动服务"; + }), +}); + +export function getModelAdapterTestResultByID(adapterID) { + const id = asString(adapterID); + if (!id) { + return null; + } + return appState.modelAdapterTestResults[id] ?? null; +} + +export function getModelAdapterTestResult(adapter) { + const normalized = normalizeModelAdapter(adapter); + if (normalized.id && appState.modelAdapterTestResults[normalized.id]) { + return appState.modelAdapterTestResults[normalized.id]; + } + const requestHash = buildModelAdapterTestRequestHash(normalized); + return Object.values(appState.modelAdapterTestResults).find((result) => result.requestHash === requestHash) ?? null; +} + +export function isModelAdapterTestResultRunning(adapter) { + return getModelAdapterTestResult(adapter)?.status === "running"; +} + +export function isModelAdapterTestResultStale(adapter, result) { + if (!result || !result.requestHash) { + return false; + } + return result.requestHash !== buildModelAdapterTestRequestHash(adapter); +} + +export async function refreshModelAdapterTestResults() { + const results = await getModelAdapterTestResults(); + applyModelAdapterTestResults(results); + return Object.values(appState.modelAdapterTestResults); +} + +export function startModelAdapterTest(adapter) { + const normalized = normalizeModelAdapter(adapter); + return testModelAdapter(normalized).then((rawResult) => { + const result = normalizeModelAdapterTestResult(rawResult); + if (result.adapterID) { + appState.modelAdapterTestResults = { + ...appState.modelAdapterTestResults, + [result.adapterID]: result, + }; + } + return result; + }); +} + +export async function runModelAdapterTest(adapter) { + return startModelAdapterTest(adapter); +} + +export async function persistUserConfig() { + const currentConfig = await loadPersistedUserConfig(); + return persistConfigPayload({ + ...currentConfig, + modelAdapters: normalizeModelAdapters(appState.modelAdapters), + routing: { + mode: appState.routingMode, + }, + homeMetrics: { + ...currentConfig.homeMetrics, + includeCacheWriteInHitRate: appState.includeCacheWriteInHitRate, + persistentMode: appState.homeMetricsPersistent, + refreshIntervalSeconds: normalizeRefreshIntervalSeconds(appState.homeMetricsRefreshInterval), + }, + tabServerBaseURL: appState.tabServerBaseURL, + tabServerToken: appState.tabServerToken, + }); +} + +export async function saveIncludeCacheWriteInHitRate(value) { + const currentConfig = await loadPersistedUserConfig(); + const previousValue = appState.includeCacheWriteInHitRate; + const nextValue = asBoolean(value); + appState.includeCacheWriteInHitRate = nextValue; + // 口径变化立即反映到运行时增量的本地命中率,与 saveHomeMetricsPersistent 的即时重算保持一致。 + appState.homeMetrics = computeDisplayMetrics(); + const result = await persistConfigPayload({ + ...currentConfig, + homeMetrics: { + ...currentConfig.homeMetrics, + includeCacheWriteInHitRate: nextValue, + }, + }); + if (!result.ok) { + appState.includeCacheWriteInHitRate = previousValue; + appState.homeMetrics = computeDisplayMetrics(); + } + return result; +} + +export async function saveHomeMetricsRefreshInterval(value) { + const currentConfig = await loadPersistedUserConfig(); + const previousValue = appState.homeMetricsRefreshInterval; + const nextValue = normalizeRefreshIntervalSeconds(value); + appState.homeMetricsRefreshInterval = nextValue; + scheduleHomeMetricsAutoRefresh(); + const result = await persistConfigPayload({ + ...currentConfig, + homeMetrics: { + ...currentConfig.homeMetrics, + refreshIntervalSeconds: nextValue, + }, + }); + if (!result.ok) { + appState.homeMetricsRefreshInterval = previousValue; + scheduleHomeMetricsAutoRefresh(); + } + return result; +} + +export async function saveHomeMetricsPersistent(value) { + const currentConfig = await loadPersistedUserConfig(); + const previousValue = appState.homeMetricsPersistent; + const nextValue = asBoolean(value, true); + appState.homeMetricsPersistent = nextValue; + appState.homeMetrics = computeDisplayMetrics(); + const result = await persistConfigPayload({ + ...currentConfig, + homeMetrics: { + ...currentConfig.homeMetrics, + persistentMode: nextValue, + }, + }); + // persistConfigPayload 内部 applyConfigToState 会用后端返回值覆盖 appState.homeMetricsPersistent, + // 需要在完成后恢复为用户选择的值(后端已保存该值,正常情况一致)。 + appState.homeMetricsPersistent = nextValue; + appState.homeMetrics = computeDisplayMetrics(); + if (!result.ok) { + appState.homeMetricsPersistent = previousValue; + appState.homeMetrics = computeDisplayMetrics(); + } + return result; +} + +export async function saveRoutingMode(mode) { + const currentConfig = await loadPersistedUserConfig(); + return persistConfigPayload({ + ...currentConfig, + routing: { + mode: normalizeRouteMode(mode), + }, + }); +} + +export async function reloadUserConfig(options = {}) { + const config = await loadPersistedUserConfig(); + applyConfigToState(config, options); + return config; +} + +export async function saveModelAdapterAt(index, adapter) { + const currentConfig = await loadPersistedUserConfig(); + const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); + const nextAdapter = normalizeModelAdapter(adapter); + const targetIndex = index >= 0 && index < nextAdapters.length ? index : nextAdapters.length; + + if (index >= 0 && index < nextAdapters.length) { + nextAdapters.splice(index, 1, nextAdapter); + } else { + nextAdapters.push(nextAdapter); + } + + const result = await persistConfigPayload( + { + ...currentConfig, + modelAdapters: nextAdapters, + }, + { modelAdaptersOnly: true }, + ); + if (!result.ok) { + return result; + } + return { + ...result, + index: targetIndex, + adapter: appState.modelAdapters[targetIndex] ?? null, + }; +} + +export async function fetchRemoteModels(adapterType, baseURL, apiKey) { + const result = await listRemoteModels(adapterType, baseURL, apiKey); + const models = Array.isArray(result?.models) ? result.models : []; + return models.map((item) => String(item?.id || "").trim()).filter(Boolean); +} + +export async function batchAddModelAdapters(inputs) { + const currentConfig = await loadPersistedUserConfig(); + const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); + const existingKeys = new Set( + nextAdapters.map((adapter) => `${adapter.type}|${adapter.baseURL}|${adapter.modelID}`), + ); + let added = 0; + for (const input of inputs) { + const adapter = normalizeModelAdapter({ + ...createEmptyModelAdapter(), + ...input, + }); + const key = `${adapter.type}|${adapter.baseURL}|${adapter.modelID}`; + if (existingKeys.has(key)) { + continue; + } + existingKeys.add(key); + nextAdapters.push(adapter); + added += 1; + } + if (added === 0) { + return { ok: true, added: 0 }; + } + const result = await persistConfigPayload( + { + ...currentConfig, + modelAdapters: nextAdapters, + }, + { modelAdaptersOnly: true }, + ); + return { ...result, added }; +} + +export async function deleteModelAdapterAt(index) { + const currentConfig = await loadPersistedUserConfig(); + const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); + + if (index < 0 || index >= nextAdapters.length) { + return { + ok: false, + error: "模型配置不存在,无法删除", + }; + } + + nextAdapters.splice(index, 1); + + return persistConfigPayload( + { + ...currentConfig, + modelAdapters: nextAdapters, + }, + { modelAdaptersOnly: true }, + ); +} + +function splitDisplayNameSeed(value) { + const text = asString(value); + const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/); + if (!match) { + return { base: text || "模型", number: 0 }; + } + const base = asString(match[1]) || "模型"; + const number = match[2] ? Number(match[2]) : 0; + return { base, number: Number.isFinite(number) ? number : 0 }; +} + +function buildNextDisplayName(existingAdapters, sourceName) { + const { base } = splitDisplayNameSeed(sourceName); + let next = 1; + const taken = new Set( + normalizeModelAdapters(existingAdapters) + .map((adapter) => adapter.displayName) + .filter(Boolean), + ); + + while (taken.has(`${base}-${next}`)) { + next += 1; + } + return `${base}-${next}`; +} + +export async function duplicateModelAdapterAt(index) { + const currentConfig = await loadPersistedUserConfig(); + const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); + + if (index < 0 || index >= nextAdapters.length) { + return { + ok: false, + error: "模型配置不存在,无法复制", + }; + } + + const source = normalizeModelAdapter(nextAdapters[index]); + const duplicate = { + ...source, + id: "", + displayName: buildNextDisplayName(nextAdapters, source.displayName || source.modelID || "模型"), + }; + + nextAdapters.splice(index + 1, 0, duplicate); + + return persistConfigPayload( + { + ...currentConfig, + modelAdapters: nextAdapters, + }, + { modelAdaptersOnly: true }, + ); +} + +export async function syncServiceState() { + const state = await getProxyState(); + applyProxyState(state); + return state; +} + +export async function syncHomeMetrics() { + const startedAt = Date.now(); + appState.homeMetricsLoading = true; + try { + const summary = await getHomeMetricsSummary(); + applyHomeMetrics(summary); + return { + ok: true, + error: "", + }; + } catch (error) { + appState.homeMetricsError = toUserError(error); + return { + ok: false, + error: appState.homeMetricsError, + }; + } finally { + const elapsed = Date.now() - startedAt; + if (elapsed < HOME_METRICS_MIN_LOADING_MS) { + await delay(HOME_METRICS_MIN_LOADING_MS - elapsed); + } + appState.homeMetricsLoading = false; + } +} + +export async function startService() { + if (appState.serviceBusy) { + return { ok: false, error: "服务状态更新中,请稍后再试" }; + } + appState.serviceBusy = true; + try { + const saved = await persistUserConfig(); + if (!saved.ok) { + return saved; + } + const state = await startProxyService(); + applyProxyState(state); + return { ok: true, error: "" }; + } catch (error) { + await syncServiceState().catch(() => {}); + return { ok: false, error: toUserError(error) }; + } finally { + appState.serviceBusy = false; + } +} + +export async function stopService() { + if (appState.serviceBusy) { + return { ok: false, error: "服务状态更新中,请稍后再试" }; + } + appState.serviceBusy = true; + try { + const state = await stopProxyService(); + applyProxyState(state); + return { ok: true, error: "" }; + } catch (error) { + await syncServiceState().catch(() => {}); + return { ok: false, error: toUserError(error) }; + } finally { + appState.serviceBusy = false; + } +} + +export async function toggleService() { + if (appState.serviceRunning) { + return stopService(); + } + return startService(); +} + +export async function openLocalLogsDirectory() { + await openLogsDirectory(); +} + +export async function openConfigWindow() { + await openConfig(); +} + +export async function openModelConfigWindow() { + await openModelConfig(); +} + +export async function openModelEditorWindow(index, adapter) { + const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter)); + await openModelEditor(index, adapterJSON); +} + +export function toUserError(error) { + const message = extractErrorMessage(error); + return message || GENERIC_SERVICE_ERROR; +} + +export async function saveTabServerConfig(baseURL, token) { + const currentConfig = await loadPersistedUserConfig(); + const nextBaseURL = asString(baseURL); + const nextToken = asString(token); + appState.tabServerBaseURL = nextBaseURL; + appState.tabServerToken = nextToken; + return persistConfigPayload({ + ...currentConfig, + tabServerBaseURL: nextBaseURL, + tabServerToken: nextToken, + }); +} + +export async function bootstrapAppState() { + try { + await reloadUserConfig(); + } catch (_error) { + // keep cached config if loading fails + } + await refreshModelAdapterTestResults().catch(() => {}); + try { + appState.appVersion = await getAppVersion(); + } catch (_error) { + appState.appVersion = ""; + } + await syncServiceState().catch(() => {}); + await syncHomeMetrics().catch(() => {}); +} diff --git a/frontend/src/style/fonts/HFKos-R.ttf b/frontend/src/style/fonts/HFKos-R.ttf new file mode 100644 index 0000000..78431a0 Binary files /dev/null and b/frontend/src/style/fonts/HFKos-R.ttf differ diff --git a/frontend/src/style/fonts/PingFang-Medium.ttf b/frontend/src/style/fonts/PingFang-Medium.ttf new file mode 100644 index 0000000..982661b Binary files /dev/null and b/frontend/src/style/fonts/PingFang-Medium.ttf differ diff --git a/frontend/src/style/global.css b/frontend/src/style/global.css new file mode 100644 index 0000000..373aca4 --- /dev/null +++ b/frontend/src/style/global.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/style/tailwind.css b/frontend/src/style/tailwind.css new file mode 100644 index 0000000..4a7b9f2 --- /dev/null +++ b/frontend/src/style/tailwind.css @@ -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; + } +} diff --git a/frontend/src/utils/isWindows.js b/frontend/src/utils/isWindows.js new file mode 100644 index 0000000..41d3fd4 --- /dev/null +++ b/frontend/src/utils/isWindows.js @@ -0,0 +1,4 @@ +import { IsWindows } from "@bindings/cursor/internal/bridge/proxyservice.js"; +import { ref } from "vue"; + +export const isWindows = ref(Boolean(await IsWindows())); diff --git a/frontend/src/utils/numberFormat.js b/frontend/src/utils/numberFormat.js new file mode 100644 index 0000000..8a67c95 --- /dev/null +++ b/frontend/src/utils/numberFormat.js @@ -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}`; +} diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..2bb8a94 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,315 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/ModelConfig.vue b/frontend/src/views/ModelConfig.vue new file mode 100644 index 0000000..8d75d60 --- /dev/null +++ b/frontend/src/views/ModelConfig.vue @@ -0,0 +1,484 @@ + + + diff --git a/frontend/src/views/ModelEditor.vue b/frontend/src/views/ModelEditor.vue new file mode 100644 index 0000000..d4ed2fb --- /dev/null +++ b/frontend/src/views/ModelEditor.vue @@ -0,0 +1,548 @@ + + +