mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8d1c9e3d | ||
|
|
7a67e76617 | ||
|
|
08ce12aa55 | ||
|
|
8f8d28880d | ||
|
|
988ba63d40 | ||
|
|
edd59dc684 | ||
|
|
da5fa34a4d | ||
|
|
4bd2359282 | ||
|
|
7838ffc6a2 | ||
|
|
ee30775be1 | ||
|
|
80a5093aa9 | ||
|
|
b9742b1667 | ||
|
|
7d3e74be59 | ||
|
|
1285bf9d62 | ||
|
|
7a724595eb | ||
|
|
00aec40e50 | ||
|
|
c10a2d475d | ||
|
|
4864d3675b | ||
|
|
3f95318a49 | ||
|
|
9373e57ebf | ||
|
|
f1992b0cfe | ||
|
|
67a9c27931 | ||
|
|
3cf8bdbc3c | ||
|
|
684953a80b | ||
|
|
297b56aed0 | ||
|
|
2393df1cb8 | ||
|
|
7d622dd039 | ||
|
|
1a7a20c519 | ||
|
|
9eb24bb4d4 | ||
|
|
426bdd6592 | ||
|
|
fa36dc2c60 | ||
|
|
4e649d676f | ||
|
|
917a711b42 | ||
|
|
270bcdb54e | ||
|
|
c6d719d9b9 | ||
|
|
a7f9494828 | ||
|
|
225de8bb85 | ||
|
|
5bd39ed317 | ||
|
|
26502ebec8 | ||
|
|
2e265d415e | ||
|
|
85a43115c7 | ||
|
|
b475166ba8 | ||
|
|
0d1cedda5b | ||
|
|
c7fe198110 | ||
|
|
8c4baf7ade | ||
|
|
4e0d2ee703 | ||
|
|
e1dacab268 | ||
|
|
da15109312 | ||
|
|
320b9fb7b9 | ||
|
|
c55c575a58 | ||
|
|
d3adfffbd8 | ||
|
|
4e9335d82f | ||
|
|
374ff9c217 | ||
|
|
06ab0d8dae | ||
|
|
2f47f02497 | ||
|
|
058aaa532e | ||
|
|
622a57fed7 | ||
|
|
f5a7347f44 | ||
|
|
a9b406ec30 | ||
|
|
639c452a00 | ||
|
|
0c9a07e018 | ||
|
|
1adca7f600 | ||
|
|
5df3e8f512 | ||
|
|
a2e3a07773 | ||
|
|
6b7c077448 | ||
|
|
c64911e8c6 | ||
|
|
d1d56bd6eb | ||
|
|
ee47f187f0 | ||
|
|
726f082e6b | ||
|
|
424235f11d | ||
|
|
58770aa206 | ||
|
|
eb0ad7b636 | ||
|
|
697fa99245 | ||
|
|
1854a3ab9a | ||
|
|
5742073be5 | ||
|
|
90d35085c0 | ||
|
|
7764eca0c3 | ||
|
|
a5437e60fe | ||
|
|
674de0b041 | ||
|
|
d1ffd1a95c | ||
|
|
9d316c0b3d | ||
|
|
834288839b | ||
|
|
68e63c7a72 | ||
|
|
2c650b3765 | ||
|
|
14b286a466 | ||
|
|
828e0a9941 | ||
|
|
00f52166b2 | ||
|
|
3349b13a2b | ||
|
|
4451a6df3b | ||
|
|
7b1c1d39af | ||
|
|
17f30a8852 | ||
|
|
1f976b2a5e | ||
|
|
f3a7aece9d | ||
|
|
3a3be0c9e9 | ||
|
|
9e05739969 | ||
|
|
4bb0cbc1fc | ||
|
|
0f6b0f9484 | ||
|
|
e401303f64 | ||
|
|
799dbda7e0 | ||
|
|
f3cbf1e6c3 | ||
|
|
2380a0d692 | ||
|
|
1da1faa4d4 | ||
|
|
ce05b0bf7a | ||
|
|
44a47992d9 | ||
|
|
319bd3d7d8 | ||
|
|
12c7973a06 | ||
|
|
f93b18740d | ||
|
|
c6ee26b12d | ||
|
|
7c1c6a134b | ||
|
|
e0aa270328 | ||
|
|
556ea21f38 | ||
|
|
315aba49c2 | ||
|
|
6ab537ad4e | ||
|
|
6d7a4e32fe | ||
|
|
50ab63de3d | ||
|
|
802daa8fcd | ||
|
|
eb2c236699 | ||
|
|
86a655fb5d | ||
|
|
c50e7d0606 | ||
|
|
4ec7b4289f | ||
|
|
6bebe37bfc |
@@ -23,6 +23,29 @@ description: 本地模式实现指南
|
||||
客户端是:/Users/leokun/Library/Application\ Support/Cursor
|
||||
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
|
||||
|
||||
## 可选抓包调试工具
|
||||
|
||||
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
|
||||
|
||||
- `agent.v1.AgentService/RunSSE`
|
||||
- `aiserver.v1.BidiService/BidiAppend`
|
||||
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
|
||||
- 同一 `request_id` 对应的上下行消息
|
||||
|
||||
开发者启动工具后,需要自行完成以下配置:
|
||||
|
||||
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`。
|
||||
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
|
||||
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||
|
||||
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
|
||||
|
||||
## Cursor 客户端格式化快照
|
||||
|
||||
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
|
||||
@@ -319,3 +342,15 @@ description: 本地模式实现指南
|
||||
- `PendingInteraction`
|
||||
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
|
||||
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
|
||||
|
||||
### 5.1 checkpoint 投影必须幂等且只有一个事实源
|
||||
|
||||
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
|
||||
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
|
||||
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
|
||||
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
|
||||
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
|
||||
- 将 `turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage`、`ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
|
||||
- 将 `root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`。
|
||||
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
|
||||
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: i18n-requirements
|
||||
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
|
||||
---
|
||||
|
||||
# I18n Requirements
|
||||
|
||||
## Source Messages
|
||||
|
||||
- Treat `zh-CN` as the only source locale.
|
||||
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
|
||||
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
|
||||
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
|
||||
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
|
||||
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
|
||||
|
||||
## Generated Catalogs
|
||||
|
||||
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
|
||||
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
|
||||
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
|
||||
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
|
||||
|
||||
## Adding A Locale
|
||||
|
||||
Update all of these integration points together:
|
||||
|
||||
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
|
||||
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
|
||||
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
|
||||
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
|
||||
- Native tray labels in `internal/app/runner.go`.
|
||||
|
||||
## Verification
|
||||
|
||||
After the scan build:
|
||||
|
||||
1. Confirm `npm run build` succeeds.
|
||||
2. Confirm every locale JSON has the same keys as `catalog.json`.
|
||||
3. Confirm non-source locale files contain no empty values.
|
||||
4. Confirm translated placeholders match the source entry placeholders.
|
||||
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "I18n Requirements"
|
||||
short_description: "Keep UI translations and generated catalogs in sync"
|
||||
default_prompt: "Use $i18n-requirements to update localized UI text safely."
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
name: test-requirements
|
||||
description: 本仓库代码禁止写任何测试
|
||||
---
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "测试要求"
|
||||
short_description: "根据测试要求,生成测试用例"
|
||||
default_prompt: "使用 $test-requirements 来根据测试要求,生成测试用例。"
|
||||
@@ -0,0 +1,12 @@
|
||||
## 变更说明 / What
|
||||
|
||||
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
|
||||
|
||||
## 关联 Issue / Related Issue
|
||||
|
||||
<!-- 例如 / e.g. Closes #161 -->
|
||||
|
||||
## 测试方式 / How to Test
|
||||
|
||||
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
|
||||
|
||||
@@ -15,6 +15,7 @@ server-go/log/
|
||||
.cursor-local-assistant
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
proto/extensions-cursor-app/
|
||||
ads-server-linux-amd64.tar
|
||||
cmd/ads-server/*.db
|
||||
cmd/ads-server/*.db-*
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 贡献指南
|
||||
|
||||
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
|
||||
|
||||
感谢你考虑为 cursor-byok 做出贡献!
|
||||
|
||||
## 开发环境
|
||||
|
||||
| 依赖 | 版本要求 |
|
||||
|------|---------|
|
||||
| Go | >= 1.25 |
|
||||
| Node.js | >= 20 |
|
||||
| Yarn | 1.x (classic) |
|
||||
| [Task](https://taskfile.dev) | >= 3 |
|
||||
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
|
||||
|
||||
Linux 额外依赖:`libgtk-3-dev`、`libwebkit2gtk-4.1-dev`(Wails 运行时需要)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装前端依赖
|
||||
cd frontend && yarn install --frozen-lockfile && cd ..
|
||||
|
||||
# 启动开发模式(热重载)
|
||||
task dev
|
||||
|
||||
# 构建当前平台分发包
|
||||
task build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── main.go # 入口
|
||||
├── internal/ # Go 后端(代理、转发、客户端管理等)
|
||||
├── frontend/ # Vue 3 + Vite + Tailwind 前端
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU)
|
||||
│ │ └── state/ # 全局状态
|
||||
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
|
||||
├── prompt/ # 内置 Agent prompt 模板
|
||||
├── proto/ # Protobuf 定义
|
||||
├── build/ # 构建配置与平台 Taskfile
|
||||
├── scripts/ # 辅助脚本(release、metrics)
|
||||
└── Taskfile.yml # 顶层任务编排
|
||||
```
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 提交信息
|
||||
|
||||
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
|
||||
|
||||
```
|
||||
feat(proxy): 支持自定义 upstream 超时
|
||||
fix(i18n): 补全日语翻译缺失 key
|
||||
release: 0.0.42
|
||||
```
|
||||
|
||||
### 代码风格
|
||||
|
||||
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
|
||||
- 前端:Vue SFC + Composition API,Tailwind 工具类优先。
|
||||
- 新增 UI 文案必须同步更新所有 locale 文件(`frontend/src/i18n/locales/`)。
|
||||
|
||||
### 分支与 PR
|
||||
|
||||
1. 从 `main` 创建功能分支:`feat/xxx`、`fix/xxx`。
|
||||
2. 保持 PR 小而聚焦,一个 PR 解决一个问题。
|
||||
3. PR 描述中说明动机和测试方式。
|
||||
|
||||
## 构建与发布
|
||||
|
||||
```bash
|
||||
# 构建全平台(仅 macOS 主机)
|
||||
task build:all
|
||||
|
||||
# 准备发布资产
|
||||
task release:prepare
|
||||
|
||||
# 发布到 GitHub Releases
|
||||
task release:github
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
提交代码即表示你同意以 [MIT License](./LICENSE) 授权你的贡献。
|
||||
@@ -0,0 +1,90 @@
|
||||
# Contributing Guide
|
||||
|
||||
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
|
||||
|
||||
Thank you for considering contributing to cursor-byok!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Dependency | Version |
|
||||
|------------|---------|
|
||||
| Go | >= 1.25 |
|
||||
| Node.js | >= 20 |
|
||||
| Yarn | 1.x (classic) |
|
||||
| [Task](https://taskfile.dev) | >= 3 |
|
||||
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
|
||||
|
||||
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
cd frontend && yarn install --frozen-lockfile && cd ..
|
||||
|
||||
# Start dev mode (hot reload)
|
||||
task dev
|
||||
|
||||
# Build for current platform
|
||||
task build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── main.go # Entry point
|
||||
├── internal/ # Go backend (proxy, forwarding, client management)
|
||||
├── frontend/ # Vue 3 + Vite + Tailwind frontend
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # Pages
|
||||
│ │ ├── components/ # Components
|
||||
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
|
||||
│ │ └── state/ # Global state
|
||||
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
|
||||
├── prompt/ # Built-in agent prompt templates
|
||||
├── proto/ # Protobuf definitions
|
||||
├── build/ # Build configs & platform Taskfiles
|
||||
├── scripts/ # Helper scripts (release, metrics)
|
||||
└── Taskfile.yml # Top-level task orchestration
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(proxy): support custom upstream timeout
|
||||
fix(i18n): add missing Japanese translation keys
|
||||
release: 0.0.42
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- Go: follow `gofmt` / `go vet`; no additional linter config.
|
||||
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
|
||||
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
|
||||
|
||||
### Branching & PRs
|
||||
|
||||
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
|
||||
2. Keep PRs small and focused — one problem per PR.
|
||||
3. Describe motivation and how to test in the PR description.
|
||||
|
||||
## Build & Release
|
||||
|
||||
```bash
|
||||
# Build all platforms (macOS host only)
|
||||
task build:all
|
||||
|
||||
# Prepare release assets
|
||||
task release:prepare
|
||||
|
||||
# Publish to GitHub Releases
|
||||
task release:github
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
|
||||
cursor-byok 是 Cursor 后端的本地实现。
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
</div>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 项目介绍
|
||||
|
||||
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
|
||||
|
||||
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
|
||||
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
|
||||
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
|
||||
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
|
||||
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
|
||||
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
|
||||
- **跨平台运行**:支持 macOS、Windows 和 Linux。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
|
||||
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
|
||||
3. 测试模型配置;测试通过后返回主界面启动服务。
|
||||
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
|
||||
|
||||
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
|
||||
|
||||
## 模型管理
|
||||
|
||||
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
|
||||
|
||||

|
||||
|
||||
## 工作原理
|
||||
|
||||
```text
|
||||
Cursor 客户端
|
||||
│
|
||||
│ Agent 请求与工具结果
|
||||
▼
|
||||
cursor-byok 本地服务
|
||||
│
|
||||
│ OpenAI / Anthropic 兼容请求
|
||||
▼
|
||||
你配置的模型 API
|
||||
```
|
||||
|
||||
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。
|
||||
|
||||
## 为什么做这个项目
|
||||
|
||||
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。
|
||||
|
||||
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。
|
||||
|
||||
## 路线图
|
||||
|
||||
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。
|
||||
|
||||
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。
|
||||
|
||||
## 社区与支持
|
||||
|
||||
- [使用教程](https://docs.leokun.cn)
|
||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
||||
- [Telegram 交流群](https://t.me/cursor_byok)
|
||||
- QQ 交流群:`1095916242`、`1094411438`、`1095918002`、`1094419321`
|
||||
|
||||
|
||||
## 开发与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
|
||||
## 贡献者名单
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](./LICENSE) 开源。
|
||||
@@ -1,46 +1,110 @@
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
|
||||
<div align="center">
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
|
||||
# cursor-byok
|
||||
cursor-byok is a local implementation of Cursor's backend.
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
[User Guide](https://docs.leokun.cn) · [Download](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [中文版本说明](./README-CN.md)
|
||||
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
[](https://github.com/leookun/cursor-byok/releases)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/leookun/cursor-byok/releases/latest)
|
||||
|
||||
|
||||
|
||||
## 为什么做这个项目
|
||||
</div>
|
||||
|
||||
公司喜欢把 Agent 服务与模型绑定在一起,让用户只能在指定模型、指定订阅和指定计费方式下使用工具。
|
||||

|
||||
|
||||
我希望打破这种绑定关系:模型应该可以自由选择。开发者应该能够把自己的模型 API 接入到任何 IDE、Chat、Agent 或开发工具中,也可以自托管整套服务,避免被单一平台锁定。
|
||||

|
||||
|
||||
这个项目的目标,是让模型选择权重新回到用户手里。
|
||||
## About
|
||||
|
||||
## 路线图
|
||||
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
|
||||
|
||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
[详细使用教程](https://dcne38qm5vlg.feishu.cn/wiki/JeP7wdGnziBXuikNaF5czWbrn8c)
|
||||
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
|
||||
|
||||
## 后续
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
|
||||
|
||||
后续会继续扩展更多工具和使用场景,包括但不限于:
|
||||
## Features
|
||||
|
||||
- 支持更多 IDE 接入
|
||||
- 支持更多 Chat 类应用
|
||||
- 支持更多 Agent 工具和工作流
|
||||
- 提供更完善的自托管部署方式
|
||||
- 持续优化不同模型 API 的兼容性
|
||||
- 降低接入成本,让已有模型额度可以被更充分地利用
|
||||
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
|
||||
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
|
||||
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
|
||||
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
|
||||
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
|
||||
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
|
||||
- **Cross-platform:** Run on macOS, Windows, and Linux.
|
||||
|
||||
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
|
||||
## Quick Start
|
||||
|
||||
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
|
||||
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
|
||||
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
|
||||
4. Open Cursor, select the configured model, and start using Agent.
|
||||
|
||||
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
|
||||
|
||||
## Model Management
|
||||
|
||||
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
|
||||
|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
```text
|
||||
Cursor client
|
||||
│
|
||||
│ Agent requests and tool results
|
||||
▼
|
||||
cursor-byok local service
|
||||
│
|
||||
│ OpenAI- / Anthropic-compatible requests
|
||||
▼
|
||||
Your model API
|
||||
```
|
||||
|
||||
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
|
||||
|
||||
## Why This Project
|
||||
|
||||
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
|
||||
|
||||
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
|
||||
|
||||
## Roadmap
|
||||
|
||||
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
|
||||
|
||||
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
|
||||
|
||||
## Community and Support
|
||||
|
||||
- [User Guide](https://docs.leokun.cn)
|
||||
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
|
||||
- [Telegram community](https://t.me/cursor_byok)
|
||||
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
|
||||
|
||||
|
||||
|
||||
## Development and Contributing
|
||||
|
||||
## Star History
|
||||
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=leookun%2Fcursor-byok&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=leookun/cursor-byok&type=timeline&legend=top-left" />
|
||||
</picture>
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/leookun/cursor-byok/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=leookun/cursor-byok" />
|
||||
</a>
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is open source under the [MIT License](./LICENSE).
|
||||
|
||||
|
||||
|
||||
@@ -178,6 +178,16 @@ tasks:
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
proxy-debugger:
|
||||
summary: 启动独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go run ./cmd/cursor-proxy-debugger
|
||||
|
||||
proxy-debugger:build:
|
||||
summary: 构建独立 Cursor 协议调试代理
|
||||
cmds:
|
||||
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
|
||||
ads:install:
|
||||
summary: 安装广告页依赖
|
||||
dir: '{{.TASKFILE_DIR}}/ads-page'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User') + ';$HOME\go\bin'; task build"
|
||||
pause
|
||||
@@ -0,0 +1,2 @@
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
|
||||
task build
|
||||
+9
-2
@@ -13,8 +13,8 @@ tasks:
|
||||
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。"
|
||||
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
|
||||
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
|
||||
cmds:
|
||||
- chmod +x ./proto/extract_extensions_proto.sh
|
||||
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
|
||||
@@ -29,9 +29,16 @@ tasks:
|
||||
- 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
|
||||
- ./proto/check_proto_sync.sh
|
||||
- rm -rf ./gen/agentv1 ./gen/aiserverv1
|
||||
- task: generate:proto
|
||||
|
||||
check:proto:
|
||||
summary: 检查根 proto 与扩展提取快照是否一致
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
cmds:
|
||||
- ./proto/check_proto_sync.sh
|
||||
|
||||
generate:proto:
|
||||
summary: 生成 proto Go/Connect 代码
|
||||
dir: '{{.ROOT_DIR}}'
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ info:
|
||||
description: "Cursor助手"
|
||||
copyright: "© 2026, Cursor助手"
|
||||
comments: "Cursor助手"
|
||||
version: "0.0.38"
|
||||
version: "0.0.47"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.38</string>
|
||||
<string>0.0.47</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.38</string>
|
||||
<string>0.0.47</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.38</string>
|
||||
<string>0.0.47</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.38</string>
|
||||
<string>0.0.47</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>LSUIElement</key>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
name: "Cursor助手"
|
||||
arch: ${GOARCH}
|
||||
platform: "linux"
|
||||
version: "0.0.38"
|
||||
version: "0.0.47"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.38"
|
||||
"file_version": "0.0.47"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.38",
|
||||
"ProductVersion": "0.0.47",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.38"
|
||||
!define INFO_PRODUCTVERSION "0.0.47"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "© 2026, Cursor助手"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.38" processorArchitecture="*"/>
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.47" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
proxydebugger "cursor/cursor-proxy-debugger"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := proxydebugger.Config{}
|
||||
openBrowser := true
|
||||
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
|
||||
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
|
||||
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
|
||||
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||
flag.Parse()
|
||||
|
||||
server, err := proxydebugger.New(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Cursor 协议调试代理已启动\n")
|
||||
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
|
||||
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||
if openBrowser {
|
||||
_ = browser.OpenURL(server.UIURL())
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signals
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Close(shutdownContext); err != nil {
|
||||
log.Printf("关闭调试代理失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Cursor Protocol Debugger
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
This standalone local HTTPS debugging proxy captures Cursor's `BidiAppend`, `RunSSE`, and Fork Chat traffic. It does not modify Cursor, the system proxy, or the installed client.
|
||||
|
||||
## Start
|
||||
|
||||
Run the following command from the repository root:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
Default addresses:
|
||||
|
||||
- HTTP/HTTPS proxy: `127.0.0.1:9090`
|
||||
- Debugging UI: `http://127.0.0.1:9091`
|
||||
- MITM target: `api2.cursor.sh`
|
||||
|
||||
The debugging UI opens automatically after startup.
|
||||
|
||||
## Configure Cursor
|
||||
|
||||
The tool does not modify Cursor automatically. After starting it, configure Cursor manually:
|
||||
|
||||
1. Open Cursor's proxy settings and set the proxy to the address printed by the tool. The default is `http://127.0.0.1:9090`.
|
||||
2. Open Cursor's Network settings and enable HTTP/1.1.
|
||||
3. Download the proxy CA certificate from `http://127.0.0.1:9091/api/ca.crt` and make sure Cursor trusts it.
|
||||
|
||||
Restore the original Cursor proxy and Network settings after debugging to avoid affecting normal network requests.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
```text
|
||||
-proxy-addr Proxy listen address; default: 127.0.0.1:9090
|
||||
-ui-addr Debugging UI listen address; default: 127.0.0.1:9091
|
||||
-target-host Host to decrypt and capture; default: api2.cursor.sh
|
||||
-max-exchanges Maximum number of exchanges retained in memory; default: 200
|
||||
-open Open the browser after startup; default: true
|
||||
```
|
||||
|
||||
## Data Handling
|
||||
|
||||
- HTTPS MITM is applied only to `target-host`; other CONNECT traffic passes through unchanged.
|
||||
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
|
||||
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
|
||||
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
|
||||
- Local Fork Chat is primarily client-side and only emits `NotifyConversationClone` and `UploadConversationBlobs` when clone blob synchronization is enabled and privacy settings allow it.
|
||||
- Requests can be sorted chronologically or in reverse chronological order and filtered by protocol `request_id`.
|
||||
- The UI supports Simplified Chinese and English, follows the browser language, and remembers a manual selection.
|
||||
- Captured traffic is stored only in process memory and is discarded when the process exits.
|
||||
- Sensitive HTTP headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
|
||||
- Raw bodies are retained up to 2 MiB per side by default; forwarded traffic is never truncated.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Cursor 协议调试器
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
这是一个独立运行的本地 HTTPS 调试代理,用于观察 Cursor 的 `BidiAppend`、`RunSSE` 和 Fork Chat 相关通信。它不会修改 Cursor、系统代理或已安装客户端。
|
||||
|
||||
## 启动
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
go run ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
默认监听:
|
||||
|
||||
- HTTP/HTTPS 代理:`127.0.0.1:9090`
|
||||
- 调试界面:`http://127.0.0.1:9091`
|
||||
- MITM 目标:`api2.cursor.sh`
|
||||
|
||||
启动后会自动打开调试界面。
|
||||
|
||||
## 配置 Cursor
|
||||
|
||||
工具不会自动修改 Cursor。启动后需要手动完成以下配置:
|
||||
|
||||
1. 打开 Cursor 的代理设置,将代理地址修改为工具启动时显示的地址,默认是 `http://127.0.0.1:9090`。
|
||||
2. 打开 Cursor 的 Network 设置,启用 HTTP/1.1。
|
||||
3. 从 `http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
|
||||
|
||||
调试结束后,请恢复原来的 Cursor 代理和 Network 设置,以免影响正常网络请求。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
go build -o bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
```text
|
||||
-proxy-addr 代理监听地址,默认 127.0.0.1:9090
|
||||
-ui-addr 调试界面监听地址,默认 127.0.0.1:9091
|
||||
-target-host 需要解密的目标主机,默认 api2.cursor.sh
|
||||
-max-exchanges 内存中保留的最大请求数,默认 200
|
||||
-open 启动后是否打开浏览器,默认 true
|
||||
```
|
||||
|
||||
## 数据处理
|
||||
|
||||
- 仅对 `target-host` 执行 HTTPS MITM,其他 CONNECT 流量直接透传。
|
||||
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||
- Fork Chat 相关的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。
|
||||
- 本地 Fork Chat 主要在客户端完成,只有启用克隆 blob 同步且隐私设置允许时才会产生 `NotifyConversationClone` 和 `UploadConversationBlobs` 流量。
|
||||
- 请求列表支持按抓包时间正序/倒序排列,并可按协议中的 `request_id` 过滤。
|
||||
- 调试界面支持简体中文和英文,可跟随浏览器语言并记住手动选择。
|
||||
- 抓包只保留在当前进程内存中;关闭进程后消失。
|
||||
- `Authorization`、`Cookie`、`Set-Cookie` 等 HTTP 头在界面中默认隐藏。
|
||||
- 单侧原始正文默认最多保留 2 MiB;代理转发的数据不会被截断。
|
||||
@@ -0,0 +1,88 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type captureReadCloser struct {
|
||||
source io.ReadCloser
|
||||
mu sync.Mutex
|
||||
buffer bytes.Buffer
|
||||
limit int
|
||||
size int64
|
||||
truncated bool
|
||||
done bool
|
||||
onChunk func([]byte)
|
||||
onDone func(captured []byte, size int64, truncated bool, readErr error)
|
||||
}
|
||||
|
||||
func newCaptureReadCloser(
|
||||
source io.ReadCloser,
|
||||
limit int,
|
||||
onChunk func([]byte),
|
||||
onDone func(captured []byte, size int64, truncated bool, readErr error),
|
||||
) *captureReadCloser {
|
||||
return &captureReadCloser{
|
||||
source: source,
|
||||
limit: limit,
|
||||
onChunk: onChunk,
|
||||
onDone: onDone,
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
|
||||
read, err := reader.source.Read(payload)
|
||||
if read > 0 {
|
||||
chunk := payload[:read]
|
||||
reader.mu.Lock()
|
||||
reader.size += int64(read)
|
||||
remaining := reader.limit - reader.buffer.Len()
|
||||
if remaining > 0 {
|
||||
captured := read
|
||||
if captured > remaining {
|
||||
captured = remaining
|
||||
}
|
||||
_, _ = reader.buffer.Write(chunk[:captured])
|
||||
}
|
||||
if reader.buffer.Len() >= reader.limit && reader.size > int64(reader.buffer.Len()) {
|
||||
reader.truncated = true
|
||||
}
|
||||
reader.mu.Unlock()
|
||||
if reader.onChunk != nil {
|
||||
reader.onChunk(append([]byte(nil), chunk...))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
reader.finish(err)
|
||||
}
|
||||
return read, err
|
||||
}
|
||||
|
||||
func (reader *captureReadCloser) Close() error {
|
||||
err := reader.source.Close()
|
||||
reader.finish(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (reader *captureReadCloser) finish(readErr error) {
|
||||
reader.mu.Lock()
|
||||
if reader.done {
|
||||
reader.mu.Unlock()
|
||||
return
|
||||
}
|
||||
reader.done = true
|
||||
captured := append([]byte(nil), reader.buffer.Bytes()...)
|
||||
size := reader.size
|
||||
truncated := reader.truncated
|
||||
reader.mu.Unlock()
|
||||
if reader.onDone != nil {
|
||||
reader.onDone(captured, size, truncated, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func rawHex(payload []byte) string {
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
agentprotocol "cursor/internal/backend/agent/protocol"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const maxConnectFrameBytes = 64 << 20
|
||||
|
||||
const (
|
||||
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
||||
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
||||
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
||||
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
||||
)
|
||||
|
||||
type connectFrameDecoder struct {
|
||||
buffer []byte
|
||||
messageType string
|
||||
codec string
|
||||
maxFrames int
|
||||
frameCount int
|
||||
onFrame func(FrameView)
|
||||
}
|
||||
|
||||
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
|
||||
return &connectFrameDecoder{
|
||||
messageType: messageType,
|
||||
codec: strings.TrimSpace(codec),
|
||||
maxFrames: maxFrames,
|
||||
onFrame: onFrame,
|
||||
}
|
||||
}
|
||||
|
||||
func (decoder *connectFrameDecoder) Write(payload []byte) {
|
||||
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
|
||||
return
|
||||
}
|
||||
decoder.buffer = append(decoder.buffer, payload...)
|
||||
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
|
||||
flags := decoder.buffer[0]
|
||||
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
|
||||
if length < 0 || length > maxConnectFrameBytes {
|
||||
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
|
||||
decoder.buffer = nil
|
||||
return
|
||||
}
|
||||
if len(decoder.buffer) < 5+length {
|
||||
return
|
||||
}
|
||||
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
|
||||
decoder.buffer = decoder.buffer[5+length:]
|
||||
decoder.emit(decoder.decode(flags, framePayload))
|
||||
}
|
||||
}
|
||||
|
||||
func (decoder *connectFrameDecoder) Close() {
|
||||
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
|
||||
decoder.emit(FrameView{
|
||||
Length: len(decoder.buffer),
|
||||
RawHex: clippedHex(decoder.buffer, 4096),
|
||||
Error: "流结束时仍有不完整的 Connect 帧",
|
||||
})
|
||||
}
|
||||
decoder.buffer = nil
|
||||
}
|
||||
|
||||
func (decoder *connectFrameDecoder) emit(frame FrameView) {
|
||||
frame.Index = decoder.frameCount
|
||||
decoder.frameCount++
|
||||
if decoder.onFrame != nil {
|
||||
decoder.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
|
||||
frame := FrameView{
|
||||
Flags: flags,
|
||||
Length: len(payload),
|
||||
Compressed: flags&0x01 != 0,
|
||||
EndStream: flags&0x02 != 0,
|
||||
RawHex: clippedHex(payload, 4096),
|
||||
}
|
||||
decoded := payload
|
||||
if frame.Compressed {
|
||||
var err error
|
||||
decoded, err = decompressPayload(payload, decoder.codec)
|
||||
if err != nil {
|
||||
frame.Error = err.Error()
|
||||
return frame
|
||||
}
|
||||
}
|
||||
if frame.EndStream {
|
||||
frame.Kind = "end_stream"
|
||||
frame.MessageType = "connect.error.v1.EndStreamResponse"
|
||||
frame.JSON = prettyJSON(decoded)
|
||||
return frame
|
||||
}
|
||||
|
||||
message := newMessage(decoder.messageType)
|
||||
if message == nil {
|
||||
frame.Error = "未知的 protobuf 消息类型"
|
||||
return frame
|
||||
}
|
||||
if err := proto.Unmarshal(decoded, message); err != nil {
|
||||
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
|
||||
return frame
|
||||
}
|
||||
frame.MessageType = decoder.messageType
|
||||
frame.Kind = activeOneofName(message)
|
||||
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
|
||||
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
|
||||
}
|
||||
frame.JSON = marshalProtoJSON(message)
|
||||
return frame
|
||||
}
|
||||
|
||||
func decompressPayload(payload []byte, codec string) ([]byte, error) {
|
||||
if codec != "" && !strings.EqualFold(codec, "gzip") {
|
||||
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip 解压失败:%w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
|
||||
}
|
||||
if len(decoded) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, err error) {
|
||||
switch path {
|
||||
case bidiAppendPath:
|
||||
request := &aiserverv1.BidiAppendRequest{}
|
||||
if err := proto.Unmarshal(payload, request); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
|
||||
outer := marshalProtoJSON(request)
|
||||
clientMessage, clientKind, decodeErr := agentprotocol.DecodeAgentClientMessage(request.GetData())
|
||||
if decodeErr != nil || clientMessage == nil {
|
||||
return outer, "bidi_append", requestID, decodeErr
|
||||
}
|
||||
combined := struct {
|
||||
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
|
||||
AgentClientKind string `json:"agent_client_kind"`
|
||||
AgentClient json.RawMessage `json:"agent_client_message"`
|
||||
}{
|
||||
BidiAppendRequest: json.RawMessage(outer),
|
||||
AgentClientKind: clientKind,
|
||||
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
|
||||
}
|
||||
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
|
||||
return string(formatted), clientKind, requestID, marshalErr
|
||||
}
|
||||
message, kind := unaryRequestMessage(path)
|
||||
if message == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, "", nil
|
||||
}
|
||||
|
||||
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
|
||||
message, kind := unaryResponseMessage(path)
|
||||
if message == nil {
|
||||
return "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, nil
|
||||
}
|
||||
|
||||
func unaryRequestMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
func unaryResponseMessage(path string) (proto.Message, string) {
|
||||
switch path {
|
||||
case forkBackgroundComposerPath:
|
||||
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
|
||||
case notifyConversationClonePath:
|
||||
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
|
||||
case uploadConversationBlobsPath:
|
||||
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
}
|
||||
|
||||
func decodesUnaryRequest(path string) bool {
|
||||
if path == bidiAppendPath {
|
||||
return true
|
||||
}
|
||||
message, _ := unaryRequestMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
func decodesUnaryResponse(path string) bool {
|
||||
message, _ := unaryResponseMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
func newMessage(messageType string) proto.Message {
|
||||
switch messageType {
|
||||
case "aiserver.v1.BidiRequestId":
|
||||
return &aiserverv1.BidiRequestId{}
|
||||
case "agent.v1.AgentServerMessage":
|
||||
return &agentv1.AgentServerMessage{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func marshalProtoJSON(message proto.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
payload, err := (protojson.MarshalOptions{
|
||||
UseProtoNames: true,
|
||||
EmitUnpopulated: false,
|
||||
Indent: " ",
|
||||
}).Marshal(message)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func activeOneofName(message proto.Message) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
reflected := message.ProtoReflect()
|
||||
oneofs := reflected.Descriptor().Oneofs()
|
||||
for index := 0; index < oneofs.Len(); index++ {
|
||||
oneof := oneofs.Get(index)
|
||||
field := reflected.WhichOneof(oneof)
|
||||
if field != nil {
|
||||
return string(field.Name())
|
||||
}
|
||||
}
|
||||
return string(reflected.Descriptor().Name())
|
||||
}
|
||||
|
||||
func prettyJSON(payload []byte) string {
|
||||
var target any
|
||||
if err := json.Unmarshal(payload, &target); err != nil {
|
||||
return string(payload)
|
||||
}
|
||||
formatted, err := json.MarshalIndent(target, "", " ")
|
||||
if err != nil {
|
||||
return string(payload)
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
|
||||
func clippedHex(payload []byte, max int) string {
|
||||
if len(payload) > max {
|
||||
return hex.EncodeToString(payload[:max]) + "..."
|
||||
}
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
"cursor/gen/aiserverv1"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDecodeForkTrafficRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
message proto.Message
|
||||
kind string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "fork background composer",
|
||||
path: forkBackgroundComposerPath,
|
||||
message: &aiserverv1.ForkBackgroundComposerRequest{
|
||||
SourceBcId: "bc-source",
|
||||
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||
Name: proto.String("forked chat"),
|
||||
TurnCount: proto.Uint32(4),
|
||||
},
|
||||
kind: "fork_background_composer_request",
|
||||
contains: []string{`"source_bc_id":"bc-source"`, `"turn_count":4`},
|
||||
},
|
||||
{
|
||||
name: "notify conversation clone",
|
||||
path: notifyConversationClonePath,
|
||||
message: &agentv1.NotifyConversationCloneRequest{
|
||||
ConversationId: "new-conversation",
|
||||
SourceConversationId: "source-conversation",
|
||||
SourceRequestId: "source-request",
|
||||
},
|
||||
kind: "notify_conversation_clone_request",
|
||||
contains: []string{`"conversation_id":"new-conversation"`, `"source_conversation_id":"source-conversation"`},
|
||||
},
|
||||
{
|
||||
name: "upload conversation blobs",
|
||||
path: uploadConversationBlobsPath,
|
||||
message: &agentv1.UploadConversationBlobsRequest{
|
||||
ConversationId: "new-conversation",
|
||||
Blobs: []*agentv1.BlobEntry{{
|
||||
Id: []byte{1, 2},
|
||||
Value: []byte("blob-value"),
|
||||
}},
|
||||
ChunkIndex: 1,
|
||||
TotalChunks: 2,
|
||||
},
|
||||
kind: "upload_conversation_blobs_request",
|
||||
contains: []string{`"conversation_id":"new-conversation"`, `"total_chunks":2`, `"value":"YmxvYi12YWx1ZQ=="`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := proto.Marshal(test.message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, kind, requestID, err := decodeUnaryRequest(test.path, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if kind != test.kind {
|
||||
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||
}
|
||||
if requestID != "" {
|
||||
t.Fatalf("request ID = %q, want empty", requestID)
|
||||
}
|
||||
compact := compactJSON(t, decoded)
|
||||
for _, expected := range test.contains {
|
||||
if !strings.Contains(compact, expected) {
|
||||
t.Errorf("decoded JSON does not contain %q:\n%s", expected, decoded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeForkTrafficResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
message proto.Message
|
||||
kind string
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "fork background composer",
|
||||
path: forkBackgroundComposerPath,
|
||||
message: &aiserverv1.ForkBackgroundComposerResponse{
|
||||
BcId: "bc-fork",
|
||||
SourceBcId: "bc-source",
|
||||
Mode: aiserverv1.ForkBackgroundComposerMode_FORK_BACKGROUND_COMPOSER_MODE_CONVERSATION,
|
||||
},
|
||||
kind: "fork_background_composer_response",
|
||||
contains: `"bc_id":"bc-fork"`,
|
||||
},
|
||||
{
|
||||
name: "notify conversation clone",
|
||||
path: notifyConversationClonePath,
|
||||
message: &agentv1.NotifyConversationCloneResponse{},
|
||||
kind: "notify_conversation_clone_response",
|
||||
contains: `{}`,
|
||||
},
|
||||
{
|
||||
name: "upload conversation blobs",
|
||||
path: uploadConversationBlobsPath,
|
||||
message: &agentv1.UploadConversationBlobsResponse{},
|
||||
kind: "upload_conversation_blobs_response",
|
||||
contains: `{}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload, err := proto.Marshal(test.message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, kind, err := decodeUnaryResponse(test.path, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if kind != test.kind {
|
||||
t.Fatalf("kind = %q, want %q", kind, test.kind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, decoded), test.contains) {
|
||||
t.Errorf("decoded JSON does not contain %q:\n%s", test.contains, decoded)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishResponseBodyDecodesCompressedForkResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := proto.Marshal(&aiserverv1.ForkBackgroundComposerResponse{
|
||||
BcId: "bc-fork",
|
||||
SourceBcId: "bc-source",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: newExchangeStore(1)}
|
||||
server.store.create(&Exchange{
|
||||
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||
})
|
||||
server.finishResponseBody("1", forkBackgroundComposerPath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||
|
||||
exchange, ok := server.store.get("1")
|
||||
if !ok {
|
||||
t.Fatal("exchange was not stored")
|
||||
}
|
||||
if exchange.ResponseKind != "fork_background_composer_response" {
|
||||
t.Fatalf("response kind = %q", exchange.ResponseKind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, exchange.Response.DecodedJSON), `"bc_id":"bc-fork"`) {
|
||||
t.Fatalf("unexpected decoded response:\n%s", exchange.Response.DecodedJSON)
|
||||
}
|
||||
if exchange.Response.DecodeError != "" {
|
||||
t.Fatalf("decode error = %q", exchange.Response.DecodeError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishRequestBodyDecodesCompressedCloneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload, err := proto.Marshal(&agentv1.NotifyConversationCloneRequest{
|
||||
ConversationId: "new-conversation",
|
||||
SourceConversationId: "source-conversation",
|
||||
SourceRequestId: "source-request",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{store: newExchangeStore(1)}
|
||||
server.store.create(&Exchange{
|
||||
ExchangeSummary: ExchangeSummary{ID: "1", StartedAt: time.Now()},
|
||||
})
|
||||
server.finishRequestBody("1", notifyConversationClonePath, "gzip", compressed.Bytes(), int64(compressed.Len()), false, nil)
|
||||
|
||||
exchange, ok := server.store.get("1")
|
||||
if !ok {
|
||||
t.Fatal("exchange was not stored")
|
||||
}
|
||||
if exchange.RequestKind != "notify_conversation_clone_request" {
|
||||
t.Fatalf("request kind = %q", exchange.RequestKind)
|
||||
}
|
||||
if !strings.Contains(compactJSON(t, exchange.Request.DecodedJSON), `"source_conversation_id":"source-conversation"`) {
|
||||
t.Fatalf("unexpected decoded request:\n%s", exchange.Request.DecodedJSON)
|
||||
}
|
||||
if exchange.Request.DecodeError != "" {
|
||||
t.Fatalf("decode error = %q", exchange.Request.DecodeError)
|
||||
}
|
||||
}
|
||||
|
||||
func compactJSON(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, []byte(value)); err != nil {
|
||||
t.Fatalf("compact JSON: %v\n%s", err, value)
|
||||
}
|
||||
return compact.String()
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
)
|
||||
|
||||
type exchangeContext struct {
|
||||
id string
|
||||
}
|
||||
|
||||
// Server runs the HTTPS debugging proxy and its local web UI.
|
||||
type Server struct {
|
||||
config Config
|
||||
certManager *certs.Manager
|
||||
caCertPEM []byte
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
proxyServer *http.Server
|
||||
uiServer *http.Server
|
||||
proxyLn net.Listener
|
||||
uiLn net.Listener
|
||||
runMu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a standalone Cursor protocol debugger.
|
||||
func New(config Config) (*Server, error) {
|
||||
config = config.normalized()
|
||||
if err := validateLoopbackAddress(config.UIAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager, caCertPEM, err := certs.NewGeneratedManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
certManager: manager,
|
||||
caCertPEM: caCertPEM,
|
||||
store: newExchangeStore(config.MaxExchanges),
|
||||
}
|
||||
proxyHandler, err := server.newProxyHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.proxyServer = &http.Server{
|
||||
Handler: proxyHandler,
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
server.uiServer = &http.Server{Handler: server.newUIHandler()}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start starts both listeners without modifying Cursor or system proxy settings.
|
||||
func (server *Server) Start() error {
|
||||
server.runMu.Lock()
|
||||
defer server.runMu.Unlock()
|
||||
if server.proxyLn != nil || server.uiLn != nil {
|
||||
return errors.New("调试代理已经启动")
|
||||
}
|
||||
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动代理监听失败:%w", err)
|
||||
}
|
||||
uiListener, err := net.Listen("tcp", server.config.UIAddr)
|
||||
if err != nil {
|
||||
_ = proxyListener.Close()
|
||||
return fmt.Errorf("启动调试界面失败:%w", err)
|
||||
}
|
||||
server.proxyLn = proxyListener
|
||||
server.uiLn = uiListener
|
||||
go func() { _ = server.proxyServer.Serve(proxyListener) }()
|
||||
go func() { _ = server.uiServer.Serve(uiListener) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops both listeners.
|
||||
func (server *Server) Close(ctx context.Context) error {
|
||||
server.runMu.Lock()
|
||||
proxyServer := server.proxyServer
|
||||
uiServer := server.uiServer
|
||||
server.proxyLn = nil
|
||||
server.uiLn = nil
|
||||
server.runMu.Unlock()
|
||||
var errorsList []error
|
||||
if proxyServer != nil {
|
||||
if err := proxyServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
if uiServer != nil {
|
||||
if err := uiServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errorsList...)
|
||||
}
|
||||
|
||||
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
|
||||
func (server *Server) UIAddr() string { return server.config.UIAddr }
|
||||
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
|
||||
|
||||
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
proxy.Verbose = false
|
||||
proxy.AllowHTTP2 = true
|
||||
proxy.Logger = log.New(io.Discard, "", 0)
|
||||
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
|
||||
id := exchangeID(context)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = connectionErr.Error()
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
})
|
||||
}
|
||||
proxy.Tr = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 200,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
caCertificate, err := server.certManager.CATLSCertificate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
|
||||
}
|
||||
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
|
||||
mitmAction := &goproxy.ConnectAction{
|
||||
Action: goproxy.ConnectMitm,
|
||||
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
|
||||
return baseTLSConfig(host, context)
|
||||
},
|
||||
}
|
||||
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||
if server.matchesTargetHost(host) {
|
||||
return mitmAction, host
|
||||
}
|
||||
return goproxy.OkConnect, host
|
||||
})
|
||||
|
||||
proxy.OnRequest().DoFunc(server.captureRequest)
|
||||
proxy.OnResponse().DoFunc(server.captureResponse)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
|
||||
return request, nil
|
||||
}
|
||||
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||
path := request.URL.Path
|
||||
requestCodec := requestContentCodec(path, request.Header)
|
||||
exchange := &Exchange{
|
||||
ExchangeSummary: ExchangeSummary{
|
||||
ID: id,
|
||||
StartedAt: time.Now(),
|
||||
Method: request.Method,
|
||||
URL: request.URL.String(),
|
||||
Host: request.URL.Host,
|
||||
Path: path,
|
||||
State: "pending",
|
||||
},
|
||||
Request: Payload{
|
||||
Headers: sortedHeaders(request.Header),
|
||||
ContentType: request.Header.Get("Content-Type"),
|
||||
ContentCodec: requestCodec,
|
||||
Frames: make([]FrameView, 0),
|
||||
},
|
||||
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||
}
|
||||
server.store.create(exchange)
|
||||
context.UserData = exchangeContext{id: id}
|
||||
|
||||
if request.Body == nil {
|
||||
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
|
||||
return request, nil
|
||||
}
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"aiserver.v1.BidiRequestId",
|
||||
requestCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendRequestFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
request.Body = newCaptureReadCloser(
|
||||
request.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
|
||||
id := exchangeID(context)
|
||||
if id == "" || response == nil {
|
||||
return response
|
||||
}
|
||||
path := ""
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
path = response.Request.URL.Path
|
||||
}
|
||||
responseCodec := responseContentCodec(path, response.Header)
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.Status = response.StatusCode
|
||||
exchange.State = "streaming"
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
||||
exchange.Response.ContentCodec = responseCodec
|
||||
})
|
||||
if response.Body == nil {
|
||||
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
|
||||
return response
|
||||
}
|
||||
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"agent.v1.AgentServerMessage",
|
||||
responseCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendResponseFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
response.Body = newCaptureReadCloser(
|
||||
response.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryRequest(path) && truncated {
|
||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.RequestBytes = size
|
||||
exchange.Request.Size = size
|
||||
exchange.Request.RawHex = rawHex(captured)
|
||||
exchange.Request.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Request.DecodedJSON = decodedJSON
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.RequestKind = kind
|
||||
}
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Request.DecodeError = decodeErr.Error()
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requestContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func responseContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
if !decodesUnaryResponse(path) {
|
||||
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||
return codec
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryResponse(path) && truncated {
|
||||
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.ResponseBytes = size
|
||||
exchange.Response.Size = size
|
||||
exchange.Response.RawHex = rawHex(captured)
|
||||
exchange.Response.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Response.DecodedJSON = decodedJSON
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.ResponseKind = kind
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Response.DecodeError = decodeErr.Error()
|
||||
}
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.State = "completed"
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
if len(exchange.Request.Frames) < server.config.MaxFrames {
|
||||
exchange.Request.Frames = append(exchange.Request.Frames, frame)
|
||||
}
|
||||
if frame.Kind != "" {
|
||||
exchange.RequestKind = frame.Kind
|
||||
}
|
||||
if frame.RequestID != "" {
|
||||
exchange.RequestID = frame.RequestID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
if len(exchange.Response.Frames) < server.config.MaxFrames {
|
||||
exchange.Response.Frames = append(exchange.Response.Frames, frame)
|
||||
}
|
||||
exchange.FrameCount = len(exchange.Response.Frames)
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.ResponseKind = frame.Kind
|
||||
}
|
||||
if frame.Error != "" {
|
||||
exchange.Response.DecodeError = frame.Error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
host := request.Host
|
||||
if request.URL != nil && request.URL.Host != "" {
|
||||
host = request.URL.Host
|
||||
}
|
||||
return server.matchesTargetHost(host)
|
||||
}
|
||||
|
||||
func (server *Server) matchesTargetHost(host string) bool {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
|
||||
return host == target
|
||||
}
|
||||
|
||||
func exchangeID(context *goproxy.ProxyCtx) string {
|
||||
if context == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := context.UserData.(exchangeContext)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return value.id
|
||||
}
|
||||
|
||||
func browserAddress(address string) string {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return address
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func validateLoopbackAddress(address string) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("调试界面监听地址无效:%w", err)
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return errors.New("调试界面只能监听本机回环地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type exchangeStore struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
order []string
|
||||
exchanges map[string]*Exchange
|
||||
subscribers map[chan storeEvent]struct{}
|
||||
}
|
||||
|
||||
func newExchangeStore(max int) *exchangeStore {
|
||||
return &exchangeStore{
|
||||
max: max,
|
||||
exchanges: make(map[string]*Exchange),
|
||||
subscribers: make(map[chan storeEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *exchangeStore) create(exchange *Exchange) {
|
||||
store.mu.Lock()
|
||||
store.exchanges[exchange.ID] = exchange
|
||||
store.order = append([]string{exchange.ID}, store.order...)
|
||||
for len(store.order) > store.max {
|
||||
oldest := store.order[len(store.order)-1]
|
||||
store.order = store.order[:len(store.order)-1]
|
||||
delete(store.exchanges, oldest)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "created", ID: exchange.ID})
|
||||
}
|
||||
|
||||
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
|
||||
store.mu.Lock()
|
||||
if exchange := store.exchanges[id]; exchange != nil {
|
||||
apply(exchange)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "updated", ID: id})
|
||||
}
|
||||
|
||||
func (store *exchangeStore) summaries() []ExchangeSummary {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
result := make([]ExchangeSummary, 0, len(store.order))
|
||||
for _, id := range store.order {
|
||||
if exchange := store.exchanges[id]; exchange != nil {
|
||||
result = append(result, exchange.ExchangeSummary)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (store *exchangeStore) get(id string) (Exchange, bool) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange == nil {
|
||||
return Exchange{}, false
|
||||
}
|
||||
return cloneExchange(*exchange), true
|
||||
}
|
||||
|
||||
func (store *exchangeStore) clear() {
|
||||
store.mu.Lock()
|
||||
store.order = nil
|
||||
store.exchanges = make(map[string]*Exchange)
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "cleared"})
|
||||
}
|
||||
|
||||
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
|
||||
updates := make(chan storeEvent, 32)
|
||||
store.mu.Lock()
|
||||
store.subscribers[updates] = struct{}{}
|
||||
store.mu.Unlock()
|
||||
return updates, func() {
|
||||
store.mu.Lock()
|
||||
if _, ok := store.subscribers[updates]; ok {
|
||||
delete(store.subscribers, updates)
|
||||
close(updates)
|
||||
}
|
||||
store.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (store *exchangeStore) publish(event storeEvent) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
for subscriber := range store.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExchange(exchange Exchange) Exchange {
|
||||
exchange.Request = clonePayload(exchange.Request)
|
||||
exchange.Response = clonePayload(exchange.Response)
|
||||
return exchange
|
||||
}
|
||||
|
||||
func clonePayload(payload Payload) Payload {
|
||||
payload.Headers = append([]Header(nil), payload.Headers...)
|
||||
payload.Frames = append([]FrameView(nil), payload.Frames...)
|
||||
return payload
|
||||
}
|
||||
|
||||
func elapsedMS(startedAt time.Time) int64 {
|
||||
if startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
func sortedHeaders(headers map[string][]string) []Header {
|
||||
result := make([]Header, 0, len(headers))
|
||||
for name, values := range headers {
|
||||
value := ""
|
||||
for index, item := range values {
|
||||
if index > 0 {
|
||||
value += ", "
|
||||
}
|
||||
value += item
|
||||
}
|
||||
if isSensitiveHeader(name) && value != "" {
|
||||
value = "[已隐藏]"
|
||||
}
|
||||
result = append(result, Header{Name: name, Value: value})
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
return result[left].Name < result[right].Name
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func isSensitiveHeader(name string) bool {
|
||||
switch httpCanonicalLower(name) {
|
||||
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func httpCanonicalLower(value string) string {
|
||||
buffer := make([]byte, len(value))
|
||||
for index := range value {
|
||||
character := value[index]
|
||||
if character >= 'A' && character <= 'Z' {
|
||||
character += 'a' - 'A'
|
||||
}
|
||||
buffer[index] = character
|
||||
}
|
||||
return string(buffer)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package proxydebugger
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
defaultProxyAddr = "127.0.0.1:9090"
|
||||
defaultUIAddr = "127.0.0.1:9091"
|
||||
defaultTargetHost = "api2.cursor.sh"
|
||||
defaultMaxExchanges = 200
|
||||
defaultMaxCaptureBytes = 2 << 20
|
||||
defaultMaxFrames = 2000
|
||||
)
|
||||
|
||||
// Config controls the standalone proxy debugger.
|
||||
type Config struct {
|
||||
ProxyAddr string
|
||||
UIAddr string
|
||||
TargetHost string
|
||||
MaxExchanges int
|
||||
MaxCaptureBytes int
|
||||
MaxFrames int
|
||||
}
|
||||
|
||||
func (config Config) normalized() Config {
|
||||
if config.ProxyAddr == "" {
|
||||
config.ProxyAddr = defaultProxyAddr
|
||||
}
|
||||
if config.UIAddr == "" {
|
||||
config.UIAddr = defaultUIAddr
|
||||
}
|
||||
if config.TargetHost == "" {
|
||||
config.TargetHost = defaultTargetHost
|
||||
}
|
||||
if config.MaxExchanges <= 0 {
|
||||
config.MaxExchanges = defaultMaxExchanges
|
||||
}
|
||||
if config.MaxCaptureBytes <= 0 {
|
||||
config.MaxCaptureBytes = defaultMaxCaptureBytes
|
||||
}
|
||||
if config.MaxFrames <= 0 {
|
||||
config.MaxFrames = defaultMaxFrames
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// ExchangeSummary is the compact request-list representation.
|
||||
type ExchangeSummary struct {
|
||||
ID string `json:"id"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
State string `json:"state"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
RequestKind string `json:"requestKind,omitempty"`
|
||||
ResponseKind string `json:"responseKind,omitempty"`
|
||||
FrameCount int `json:"frameCount"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Exchange contains the request and response detail shown by the debugger.
|
||||
type Exchange struct {
|
||||
ExchangeSummary
|
||||
Request Payload `json:"request"`
|
||||
Response Payload `json:"response"`
|
||||
}
|
||||
|
||||
// Payload contains headers, captured raw bytes, and decoded protobuf frames.
|
||||
type Payload struct {
|
||||
Headers []Header `json:"headers"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
ContentCodec string `json:"contentCodec,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
RawTruncated bool `json:"rawTruncated,omitempty"`
|
||||
DecodedJSON string `json:"decodedJson,omitempty"`
|
||||
DecodeError string `json:"decodeError,omitempty"`
|
||||
Frames []FrameView `json:"frames,omitempty"`
|
||||
}
|
||||
|
||||
// Header is a stable, sorted HTTP header pair.
|
||||
type Header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// FrameView describes one Connect streaming envelope.
|
||||
type FrameView struct {
|
||||
Index int `json:"index"`
|
||||
Flags uint8 `json:"flags"`
|
||||
Length int `json:"length"`
|
||||
Compressed bool `json:"compressed"`
|
||||
EndStream bool `json:"endStream"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
MessageType string `json:"messageType,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
JSON string `json:"json,omitempty"`
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type storeEvent struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webAssets embed.FS
|
||||
|
||||
func (server *Server) newUIHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/status", server.handleStatus)
|
||||
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
|
||||
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
|
||||
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
|
||||
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
|
||||
assets, _ := fs.Sub(webAssets, "web")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/", fileServer)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]any{
|
||||
"proxyAddr": server.config.ProxyAddr,
|
||||
"uiAddr": server.config.UIAddr,
|
||||
"targetHost": server.config.TargetHost,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, server.store.summaries())
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||
id := strings.TrimSpace(request.PathValue("id"))
|
||||
exchange, ok := server.store.get(id)
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, exchange)
|
||||
}
|
||||
|
||||
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||
server.store.clear()
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := writer.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
writer.Header().Set("Cache-Control", "no-cache")
|
||||
writer.Header().Set("Connection", "keep-alive")
|
||||
updates, unsubscribe := server.store.subscribe()
|
||||
defer unsubscribe()
|
||||
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case event, open := <-updates:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(writer, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||
_, _ = writer.Write(server.caCertPEM)
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
|
||||
|
||||
const state = {
|
||||
status: null,
|
||||
exchanges: [],
|
||||
selectedId: null,
|
||||
selected: null,
|
||||
search: "",
|
||||
requestId: "",
|
||||
endpoint: "all",
|
||||
sortOrder: "desc",
|
||||
paused: false,
|
||||
pendingRefresh: false,
|
||||
connection: { connected: false, key: "status.connecting", values: {} },
|
||||
tabs: {
|
||||
request: "body",
|
||||
response: "frames",
|
||||
},
|
||||
};
|
||||
|
||||
const elements = {
|
||||
statusDot: document.querySelector("#status-dot"),
|
||||
statusText: document.querySelector("#status-text"),
|
||||
proxyAddress: document.querySelector("#proxy-address"),
|
||||
targetHost: document.querySelector("#target-host"),
|
||||
connectionLabel: document.querySelector("#connection-label"),
|
||||
trafficSummary: document.querySelector("#traffic-summary"),
|
||||
searchInput: document.querySelector("#search-input"),
|
||||
requestIdInput: document.querySelector("#request-id-input"),
|
||||
endpointFilter: document.querySelector("#endpoint-filter"),
|
||||
sortOrder: document.querySelector("#sort-order"),
|
||||
requestCount: document.querySelector("#request-count"),
|
||||
requestList: document.querySelector("#request-list"),
|
||||
emptyState: document.querySelector("#empty-state"),
|
||||
selectionSummary: document.querySelector("#selection-summary"),
|
||||
requestContent: document.querySelector("#request-content"),
|
||||
responseContent: document.querySelector("#response-content"),
|
||||
pauseButton: document.querySelector("#pause-button"),
|
||||
clearButton: document.querySelector("#clear-button"),
|
||||
localeSelect: document.querySelector("#locale-select"),
|
||||
workspace: document.querySelector("#workspace"),
|
||||
splitter: document.querySelector("#horizontal-splitter"),
|
||||
};
|
||||
|
||||
async function fetchJSON(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
state.status = await fetchJSON("/api/status");
|
||||
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
|
||||
renderRuntimeStatus();
|
||||
elements.proxyAddress.textContent = `http://${state.status.proxyAddr}`;
|
||||
elements.targetHost.textContent = state.status.targetHost;
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
state.exchanges = await fetchJSON("/api/exchanges");
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
|
||||
await refreshDetail(state.selectedId);
|
||||
} else if (state.selectedId) {
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
renderDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDetail(id) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const detail = await fetchJSON(`/api/exchanges/${encodeURIComponent(id)}`);
|
||||
if (state.selectedId !== id) return;
|
||||
state.selected = detail;
|
||||
renderDetail();
|
||||
} catch (error) {
|
||||
if (state.selectedId === id) {
|
||||
state.selected = null;
|
||||
renderDetailError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (state.paused) {
|
||||
state.pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
if (state.pendingRefresh) return;
|
||||
state.pendingRefresh = true;
|
||||
window.setTimeout(async () => {
|
||||
state.pendingRefresh = false;
|
||||
try {
|
||||
await refreshList();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.refreshFailed", { message: error.message });
|
||||
}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
const events = new EventSource("/api/events");
|
||||
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
|
||||
events.addEventListener("update", scheduleRefresh);
|
||||
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
|
||||
}
|
||||
|
||||
function setConnectionState(connected, key, values = {}) {
|
||||
state.connection = { connected, key, values };
|
||||
renderConnectionState();
|
||||
}
|
||||
|
||||
function renderRuntimeStatus() {
|
||||
if (!state.status) {
|
||||
elements.statusText.textContent = t("status.connecting");
|
||||
return;
|
||||
}
|
||||
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
|
||||
}
|
||||
|
||||
function renderConnectionState() {
|
||||
const { connected, key, values } = state.connection;
|
||||
elements.connectionLabel.textContent = t(key, values);
|
||||
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
|
||||
}
|
||||
|
||||
function filteredExchanges() {
|
||||
const query = state.search.trim().toLowerCase();
|
||||
const requestId = state.requestId.trim().toLowerCase();
|
||||
const direction = state.sortOrder === "asc" ? 1 : -1;
|
||||
return state.exchanges
|
||||
.filter((item) => {
|
||||
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
|
||||
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
|
||||
if (state.endpoint === "fork" && !isForkTrafficPath(item.path)) return false;
|
||||
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
|
||||
if (!query) return true;
|
||||
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
|
||||
if (startedAtDelta !== 0) return startedAtDelta * direction;
|
||||
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function isForkTrafficPath(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
return ["forkbackgroundcomposer", "notifyconversationclone", "uploadconversationblobs"].some((endpoint) =>
|
||||
normalized.includes(endpoint),
|
||||
);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const exchanges = filteredExchanges();
|
||||
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
|
||||
elements.requestList.innerHTML = exchanges
|
||||
.map((item) => {
|
||||
const selected = item.id === state.selectedId ? " selected" : "";
|
||||
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
|
||||
const kind = item.responseKind || item.requestKind || "-";
|
||||
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
|
||||
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
|
||||
<td><code>${escapeHTML(item.id)}</code></td>
|
||||
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
|
||||
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
|
||||
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
|
||||
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
|
||||
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
|
||||
<td>${formatBytes(item.responseBytes)}</td>
|
||||
<td>${formatDuration(item.durationMs)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTrafficSummary() {
|
||||
const totals = state.exchanges.reduce(
|
||||
(result, item) => {
|
||||
result.up += item.requestBytes || 0;
|
||||
result.down += item.responseBytes || 0;
|
||||
return result;
|
||||
},
|
||||
{ up: 0, down: 0 },
|
||||
);
|
||||
elements.trafficSummary.textContent = `↑ ${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!state.selected) {
|
||||
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
|
||||
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
|
||||
return;
|
||||
}
|
||||
const item = state.selected;
|
||||
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
|
||||
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
|
||||
elements.requestContent.innerHTML = renderPayload(item.request, state.tabs.request);
|
||||
elements.responseContent.innerHTML = renderPayload(item.response, state.tabs.response);
|
||||
}
|
||||
|
||||
function renderDetailError(error) {
|
||||
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
}
|
||||
|
||||
function renderPayload(payload, tab) {
|
||||
if (!payload) return `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`;
|
||||
if (tab === "headers") return renderHeaders(payload.headers);
|
||||
if (tab === "frames") return renderFrames(payload.frames);
|
||||
if (tab === "raw") {
|
||||
const body = payload.rawHex ? formatHex(payload.rawHex) : t("notices.noRaw");
|
||||
return `<pre class="hex-view">${escapeHTML(body)}</pre>${renderTruncated(payload.rawTruncated)}`;
|
||||
}
|
||||
if (payload.decodedJson) {
|
||||
return `<pre class="code-view">${escapeHTML(payload.decodedJson)}</pre>${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`;
|
||||
}
|
||||
if (payload.frames?.length) return renderFrames(payload.frames);
|
||||
if (payload.decodeError) return `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`;
|
||||
return `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`;
|
||||
}
|
||||
|
||||
function renderHeaders(headers = []) {
|
||||
const items = Array.isArray(headers) ? headers : [];
|
||||
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
|
||||
return `<table class="headers-table"><tbody>${items
|
||||
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
|
||||
.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderFrames(frames = []) {
|
||||
const items = Array.isArray(frames) ? frames : [];
|
||||
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`;
|
||||
return `<div class="frame-list">${items
|
||||
.map((frame) => {
|
||||
const kind = frame.kind || frame.messageType || t("notices.unknown");
|
||||
const flags = `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`;
|
||||
const content = frame.json
|
||||
? `<pre class="code-view">${escapeHTML(frame.json)}</pre>`
|
||||
: `<pre class="hex-view">${escapeHTML(formatHex(frame.rawHex || ""))}</pre>`;
|
||||
return `<details class="frame-item"${frame.index === items.length - 1 ? " open" : ""}>
|
||||
<summary>
|
||||
<span class="frame-index">#${frame.index}</span>
|
||||
<span class="frame-kind" title="${escapeHTML(kind)}">${escapeHTML(kind)}</span>
|
||||
<span class="frame-size">${formatBytes(frame.length)}</span>
|
||||
<span class="frame-flags">${flags}${frame.compressed ? " gzip" : ""}</span>
|
||||
</summary>
|
||||
${frame.error ? `<div class="frame-error">${escapeHTML(frame.error)}</div>` : content}
|
||||
</details>`;
|
||||
})
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderDecodeError(error) {
|
||||
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
|
||||
}
|
||||
|
||||
function renderTruncated(truncated) {
|
||||
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
|
||||
}
|
||||
|
||||
function formatState(value) {
|
||||
const key = {
|
||||
pending: "state.pending",
|
||||
streaming: "state.streaming",
|
||||
completed: "state.completed",
|
||||
error: "state.error",
|
||||
}[value];
|
||||
return key ? t(key) : value || "-";
|
||||
}
|
||||
|
||||
function currentCopyText(side) {
|
||||
const payload = state.selected?.[side];
|
||||
if (!payload) return "";
|
||||
const tab = state.tabs[side];
|
||||
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
|
||||
if (tab === "raw") return payload.rawHex || "";
|
||||
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
|
||||
return payload.decodedJson || "";
|
||||
}
|
||||
|
||||
function formatHex(value) {
|
||||
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
|
||||
const lines = [];
|
||||
for (let index = 0; index < hex.length; index += 32) {
|
||||
const chunk = hex.slice(index, index + 32);
|
||||
const bytes = chunk.match(/.{1,2}/g) || [];
|
||||
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const milliseconds = Number(value || 0);
|
||||
if (milliseconds < 1000) return `${milliseconds} ms`;
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
elements.requestList.addEventListener("click", async (event) => {
|
||||
const row = event.target.closest("tr[data-id]");
|
||||
if (!row) return;
|
||||
state.selectedId = row.dataset.id;
|
||||
state.selected = null;
|
||||
renderList();
|
||||
renderDetail();
|
||||
await refreshDetail(state.selectedId);
|
||||
});
|
||||
|
||||
elements.searchInput.addEventListener("input", (event) => {
|
||||
state.search = event.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.requestIdInput.addEventListener("input", (event) => {
|
||||
state.requestId = event.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.endpointFilter.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-value]");
|
||||
if (!button) return;
|
||||
state.endpoint = button.dataset.value;
|
||||
for (const item of elements.endpointFilter.querySelectorAll("button")) {
|
||||
item.classList.toggle("active", item === button);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.sortOrder.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-value]");
|
||||
if (!button) return;
|
||||
state.sortOrder = button.dataset.value;
|
||||
for (const item of elements.sortOrder.querySelectorAll("button")) {
|
||||
item.classList.toggle("active", item === button);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".payload-panel").forEach((panel) => {
|
||||
panel.querySelector(".tabs").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-tab]");
|
||||
if (!button) return;
|
||||
const side = panel.dataset.side;
|
||||
state.tabs[side] = button.dataset.tab;
|
||||
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
renderDetail();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-copy-side]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const text = currentCopyText(button.dataset.copySide);
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = t("actions.copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = t("actions.copy");
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPauseState() {
|
||||
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
|
||||
const actionKey = state.paused ? "actions.resume" : "actions.pause";
|
||||
elements.pauseButton.title = t(actionKey);
|
||||
elements.pauseButton.setAttribute("aria-label", t(actionKey));
|
||||
}
|
||||
|
||||
elements.pauseButton.addEventListener("click", async () => {
|
||||
state.paused = !state.paused;
|
||||
elements.pauseButton.classList.toggle("active", state.paused);
|
||||
renderPauseState();
|
||||
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
|
||||
if (!state.paused && state.pendingRefresh) {
|
||||
state.pendingRefresh = false;
|
||||
await refreshList();
|
||||
}
|
||||
});
|
||||
|
||||
function applyLocale() {
|
||||
translateDocument();
|
||||
elements.localeSelect.value = getLocale();
|
||||
renderRuntimeStatus();
|
||||
renderConnectionState();
|
||||
renderPauseState();
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
elements.localeSelect.addEventListener("change", (event) => {
|
||||
setLocale(event.target.value);
|
||||
applyLocale();
|
||||
});
|
||||
|
||||
elements.clearButton.addEventListener("click", async () => {
|
||||
await fetchJSON("/api/exchanges", { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
await refreshList();
|
||||
renderDetail();
|
||||
});
|
||||
|
||||
let draggingSplitter = false;
|
||||
elements.splitter.addEventListener("pointerdown", (event) => {
|
||||
draggingSplitter = true;
|
||||
elements.splitter.classList.add("dragging");
|
||||
elements.splitter.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
elements.splitter.addEventListener("pointermove", (event) => {
|
||||
if (!draggingSplitter) return;
|
||||
const bounds = elements.workspace.getBoundingClientRect();
|
||||
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
|
||||
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
|
||||
});
|
||||
|
||||
elements.splitter.addEventListener("pointerup", () => {
|
||||
draggingSplitter = false;
|
||||
elements.splitter.classList.remove("dragging");
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
applyLocale();
|
||||
renderDetail();
|
||||
try {
|
||||
await Promise.all([loadStatus(), refreshList()]);
|
||||
connectEvents();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.connectFailed", { message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,180 @@
|
||||
const SOURCE_LOCALE = "zh-CN";
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
|
||||
const SUPPORTED_LOCALES = [SOURCE_LOCALE, DEFAULT_LOCALE];
|
||||
|
||||
const messages = {
|
||||
"zh-CN": {
|
||||
"app.title": "Cursor 协议调试器",
|
||||
"status.connecting": "正在连接",
|
||||
"status.running": "代理运行中",
|
||||
"status.stopped": "代理已停止",
|
||||
"actions.downloadCA": "下载代理 CA 证书",
|
||||
"actions.caCertificate": "CA 证书",
|
||||
"actions.pause": "暂停界面更新",
|
||||
"actions.resume": "继续界面更新",
|
||||
"actions.clear": "清空",
|
||||
"actions.copy": "复制",
|
||||
"actions.copied": "已复制",
|
||||
"language.label": "界面语言",
|
||||
"filters.region": "请求过滤器",
|
||||
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
|
||||
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||
"filters.endpoint": "接口过滤",
|
||||
"filters.all": "全部",
|
||||
"filters.fork": "Fork",
|
||||
"filters.sort": "排序方向",
|
||||
"filters.ascending": "正序",
|
||||
"filters.descending": "倒序",
|
||||
"count.requests": "{count} 条",
|
||||
"table.url": "网址",
|
||||
"table.message": "消息",
|
||||
"table.method": "方法",
|
||||
"table.status": "状态",
|
||||
"table.response": "响应",
|
||||
"table.duration": "耗时",
|
||||
"empty.waitingForCursor": "等待来自 Cursor 的请求",
|
||||
"splitter.resize": "调整详情区域高度",
|
||||
"selection.waiting": "等待选择",
|
||||
"selection.prompt": "选择一条请求查看详情",
|
||||
"panel.request": "请求",
|
||||
"panel.response": "响应",
|
||||
"panel.requestDetails": "请求详情",
|
||||
"panel.responseDetails": "响应详情",
|
||||
"tabs.headers": "标头",
|
||||
"tabs.body": "正文",
|
||||
"tabs.frames": "帧",
|
||||
"tabs.raw": "原始",
|
||||
"notices.noRequest": "暂无请求内容",
|
||||
"notices.noResponse": "暂无响应内容",
|
||||
"notices.noContent": "暂无内容",
|
||||
"notices.noRaw": "暂无原始数据",
|
||||
"notices.noBody": "暂无可显示的正文",
|
||||
"notices.noHeaders": "暂无标头",
|
||||
"notices.noFrames": "尚未收到完整帧",
|
||||
"notices.unknown": "未识别",
|
||||
"notices.truncated": "原始正文已达到本地抓取上限,转发内容未被截断",
|
||||
"connection.live": "实时连接中",
|
||||
"connection.retrying": "实时连接正在重试",
|
||||
"connection.refreshFailed": "刷新失败:{message}",
|
||||
"connection.paused": "界面更新已暂停",
|
||||
"connection.connectFailed": "连接失败:{message}",
|
||||
"state.pending": "等待中",
|
||||
"state.streaming": "传输中",
|
||||
"state.completed": "已完成",
|
||||
"state.error": "错误",
|
||||
},
|
||||
"en-US": {
|
||||
"app.title": "Cursor Protocol Debugger",
|
||||
"status.connecting": "Connecting",
|
||||
"status.running": "Proxy running",
|
||||
"status.stopped": "Proxy stopped",
|
||||
"actions.downloadCA": "Download proxy CA certificate",
|
||||
"actions.caCertificate": "CA Certificate",
|
||||
"actions.pause": "Pause UI updates",
|
||||
"actions.resume": "Resume UI updates",
|
||||
"actions.clear": "Clear",
|
||||
"actions.copy": "Copy",
|
||||
"actions.copied": "Copied",
|
||||
"language.label": "Interface language",
|
||||
"filters.region": "Request filters",
|
||||
"filters.urlPlaceholder": "Filter by URL, message type, or status",
|
||||
"filters.requestIdPlaceholder": "Filter by Request ID",
|
||||
"filters.endpoint": "Endpoint filter",
|
||||
"filters.all": "All",
|
||||
"filters.fork": "Fork",
|
||||
"filters.sort": "Sort order",
|
||||
"filters.ascending": "Oldest first",
|
||||
"filters.descending": "Newest first",
|
||||
"count.requests": "{count} requests",
|
||||
"table.url": "URL",
|
||||
"table.message": "Message",
|
||||
"table.method": "Method",
|
||||
"table.status": "Status",
|
||||
"table.response": "Response",
|
||||
"table.duration": "Duration",
|
||||
"empty.waitingForCursor": "Waiting for requests from Cursor",
|
||||
"splitter.resize": "Resize details area",
|
||||
"selection.waiting": "No selection",
|
||||
"selection.prompt": "Select a request to inspect its details",
|
||||
"panel.request": "Request",
|
||||
"panel.response": "Response",
|
||||
"panel.requestDetails": "Request details",
|
||||
"panel.responseDetails": "Response details",
|
||||
"tabs.headers": "Headers",
|
||||
"tabs.body": "Body",
|
||||
"tabs.frames": "Frames",
|
||||
"tabs.raw": "Raw",
|
||||
"notices.noRequest": "No request content",
|
||||
"notices.noResponse": "No response content",
|
||||
"notices.noContent": "No content",
|
||||
"notices.noRaw": "No raw data",
|
||||
"notices.noBody": "No body available",
|
||||
"notices.noHeaders": "No headers",
|
||||
"notices.noFrames": "No complete frames received yet",
|
||||
"notices.unknown": "Unknown",
|
||||
"notices.truncated": "Raw body reached the local capture limit; forwarded data was not truncated",
|
||||
"connection.live": "Live connection",
|
||||
"connection.retrying": "Reconnecting live updates",
|
||||
"connection.refreshFailed": "Refresh failed: {message}",
|
||||
"connection.paused": "UI updates paused",
|
||||
"connection.connectFailed": "Connection failed: {message}",
|
||||
"state.pending": "Pending",
|
||||
"state.streaming": "Streaming",
|
||||
"state.completed": "Completed",
|
||||
"state.error": "Error",
|
||||
},
|
||||
};
|
||||
|
||||
function matchLocale(locale) {
|
||||
const normalized = String(locale || "").trim().replaceAll("_", "-").toLowerCase();
|
||||
if (!normalized) return "";
|
||||
const exact = SUPPORTED_LOCALES.find((candidate) => candidate.toLowerCase() === normalized);
|
||||
if (exact) return exact;
|
||||
return normalized.split("-")[0] === "zh" ? SOURCE_LOCALE : normalized.split("-")[0] === "en" ? DEFAULT_LOCALE : "";
|
||||
}
|
||||
|
||||
function resolveInitialLocale() {
|
||||
const stored = matchLocale(window.localStorage.getItem(STORAGE_KEY));
|
||||
if (stored) return stored;
|
||||
for (const candidate of navigator.languages || [navigator.language]) {
|
||||
const matched = matchLocale(candidate);
|
||||
if (matched) return matched;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
let currentLocale = resolveInitialLocale();
|
||||
|
||||
export function getLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function t(key, values = {}) {
|
||||
const template = messages[currentLocale]?.[key] || messages[SOURCE_LOCALE][key] || key;
|
||||
return template.replace(/\{(\w+)\}/g, (_match, name) => String(values[name] ?? ""));
|
||||
}
|
||||
|
||||
export function translateDocument(root = document) {
|
||||
document.documentElement.lang = currentLocale;
|
||||
document.title = t("app.title");
|
||||
for (const element of root.querySelectorAll("[data-i18n]")) {
|
||||
element.textContent = t(element.dataset.i18n);
|
||||
}
|
||||
for (const [attribute, dataAttribute] of [
|
||||
["aria-label", "i18nAriaLabel"],
|
||||
["placeholder", "i18nPlaceholder"],
|
||||
["title", "i18nTitle"],
|
||||
]) {
|
||||
for (const element of root.querySelectorAll(`[data-${dataAttribute.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}]`)) {
|
||||
element.setAttribute(attribute, t(element.dataset[dataAttribute]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
currentLocale = matchLocale(locale) || DEFAULT_LOCALE;
|
||||
window.localStorage.setItem(STORAGE_KEY, currentLocale);
|
||||
translateDocument();
|
||||
return currentLocale;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Cursor 协议调试器</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<strong data-i18n="app.title">Cursor 协议调试器</strong>
|
||||
</div>
|
||||
<div class="runtime-status" aria-live="polite">
|
||||
<span id="status-dot" class="status-dot"></span>
|
||||
<span id="status-text" data-i18n="status.connecting">正在连接</span>
|
||||
<code id="proxy-address"></code>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="locale-picker">
|
||||
<span class="visually-hidden" data-i18n="language.label">界面语言</span>
|
||||
<select id="locale-select" aria-label="界面语言" title="界面语言" data-i18n-aria-label="language.label" data-i18n-title="language.label">
|
||||
<option value="zh-CN">中文</option>
|
||||
<option value="en-US">EN</option>
|
||||
</select>
|
||||
</label>
|
||||
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
|
||||
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause">Ⅱ</button>
|
||||
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filterbar" aria-label="请求过滤器" data-i18n-aria-label="filters.region">
|
||||
<div class="search-box url-filter">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input id="search-input" type="search" placeholder="过滤 URL、请求类型或状态" data-i18n-placeholder="filters.urlPlaceholder" autocomplete="off" />
|
||||
</div>
|
||||
<div class="search-box request-id-filter">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
|
||||
</div>
|
||||
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
|
||||
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
|
||||
<button type="button" data-value="runsse">RunSSE</button>
|
||||
<button type="button" data-value="bidiappend">BidiAppend</button>
|
||||
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
|
||||
</div>
|
||||
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
|
||||
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
|
||||
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
|
||||
</div>
|
||||
<span id="request-count" class="request-count">0 条</span>
|
||||
</section>
|
||||
|
||||
<main id="workspace" class="workspace">
|
||||
<section class="request-list-pane">
|
||||
<table class="request-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="status-column"></th>
|
||||
<th class="index-column">#</th>
|
||||
<th data-i18n="table.url">网址</th>
|
||||
<th class="request-id-column">Request ID</th>
|
||||
<th class="kind-column" data-i18n="table.message">消息</th>
|
||||
<th class="method-column" data-i18n="table.method">方法</th>
|
||||
<th class="code-column" data-i18n="table.status">状态</th>
|
||||
<th class="size-column" data-i18n="table.response">响应</th>
|
||||
<th class="time-column" data-i18n="table.duration">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="request-list"></tbody>
|
||||
</table>
|
||||
<div id="empty-state" class="empty-state" data-i18n="empty.waitingForCursor">等待来自 Cursor 的请求</div>
|
||||
</section>
|
||||
|
||||
<div id="horizontal-splitter" class="horizontal-splitter" role="separator" aria-label="调整详情区域高度" data-i18n-aria-label="splitter.resize"></div>
|
||||
|
||||
<section id="detail-pane" class="detail-pane">
|
||||
<div id="selection-summary" class="selection-summary">
|
||||
<span class="method-badge">POST</span>
|
||||
<span class="status-badge" data-i18n="selection.waiting">等待选择</span>
|
||||
<code data-i18n="selection.prompt">选择一条请求查看详情</code>
|
||||
</div>
|
||||
<div class="detail-columns">
|
||||
<section class="payload-panel" data-side="request">
|
||||
<div class="panel-header">
|
||||
<strong data-i18n="panel.request">请求</strong>
|
||||
<nav class="tabs" aria-label="请求详情" data-i18n-aria-label="panel.requestDetails">
|
||||
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
|
||||
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
|
||||
<button type="button" data-tab="frames" data-i18n="tabs.frames">帧</button>
|
||||
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
|
||||
</nav>
|
||||
<button class="copy-button" type="button" data-copy-side="request" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
|
||||
</div>
|
||||
<div id="request-content" class="panel-content"></div>
|
||||
</section>
|
||||
<section class="payload-panel" data-side="response">
|
||||
<div class="panel-header">
|
||||
<strong data-i18n="panel.response">响应</strong>
|
||||
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
|
||||
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
|
||||
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
|
||||
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames">帧</button>
|
||||
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
|
||||
</nav>
|
||||
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
|
||||
</div>
|
||||
<div id="response-content" class="panel-content"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
|
||||
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
|
||||
<span id="target-host"></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,921 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #171818;
|
||||
color: #dedfdd;
|
||||
font-synthesis: none;
|
||||
--surface-0: #171818;
|
||||
--surface-1: #1d1f1f;
|
||||
--surface-2: #242626;
|
||||
--surface-3: #2c2f2f;
|
||||
--border: #343737;
|
||||
--border-strong: #454949;
|
||||
--muted: #8d9390;
|
||||
--text: #dedfdd;
|
||||
--accent: #4ea58b;
|
||||
--accent-soft: #25473d;
|
||||
--cyan: #55a8ba;
|
||||
--orange: #c88762;
|
||||
--danger: #c56d65;
|
||||
--selection: #245b73;
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
a {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 48px 46px minmax(0, 1fr) 26px;
|
||||
min-width: 760px;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.filterbar,
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-color: var(--border);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.runtime-status,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--surface-1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-width: 240px;
|
||||
color: #bec3c0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.runtime-status code {
|
||||
overflow: hidden;
|
||||
max-width: 260px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #7d8380;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #42bd79;
|
||||
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.locale-picker {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.locale-picker select {
|
||||
width: 58px;
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
padding: 0 6px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.button,
|
||||
.icon-button,
|
||||
.copy-button {
|
||||
height: 29px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.icon-button:hover,
|
||||
.copy-button:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
border-color: #744640;
|
||||
color: #f2b0aa;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 31px;
|
||||
padding: 0;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-button.active {
|
||||
border-color: var(--orange);
|
||||
color: #f1bb98;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
gap: 10px;
|
||||
padding: 7px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
max-width: 640px;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: #191b1b;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-box > span {
|
||||
padding-left: 9px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 0 9px;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #6f7572;
|
||||
}
|
||||
|
||||
.url-filter {
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.request-id-filter {
|
||||
flex: 0 1 320px;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.segmented-control {
|
||||
display: flex;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segmented-control button {
|
||||
min-width: 62px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 0 10px;
|
||||
background: #1b1d1d;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.segmented-control button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.segmented-control button.active {
|
||||
background: var(--accent-soft);
|
||||
color: #bce8d9;
|
||||
}
|
||||
|
||||
.sort-control button {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-list-pane {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #181a1a;
|
||||
}
|
||||
|
||||
.request-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.request-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #202222;
|
||||
}
|
||||
|
||||
.request-table th,
|
||||
.request-table td {
|
||||
height: 30px;
|
||||
border-right: 1px solid #2c2f2f;
|
||||
border-bottom: 1px solid #292c2c;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-table th {
|
||||
color: #9da29f;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.request-table tbody tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.request-table tbody tr:hover {
|
||||
background: #222525;
|
||||
}
|
||||
|
||||
.request-table tbody tr.selected {
|
||||
background: var(--selection);
|
||||
color: #f3f8f8;
|
||||
}
|
||||
|
||||
.request-table code {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.status-column {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.index-column {
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.request-id-column {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.kind-column {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.method-column {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.code-column {
|
||||
width: 66px;
|
||||
}
|
||||
|
||||
.size-column {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.row-state {
|
||||
display: block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border-radius: 50%;
|
||||
background: #7c8380;
|
||||
}
|
||||
|
||||
.row-state.streaming {
|
||||
background: #45ba77;
|
||||
}
|
||||
|
||||
.row-state.completed {
|
||||
background: var(--cyan);
|
||||
}
|
||||
|
||||
.row-state.error {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.method-text {
|
||||
color: #61b9df;
|
||||
font-family: var(--mono);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: #68c991;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: #e18b83;
|
||||
}
|
||||
|
||||
.kind-text {
|
||||
color: #d3a17f;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.request-id-text {
|
||||
color: #8bc2cc;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 34px 0 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #686e6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.horizontal-splitter {
|
||||
cursor: row-resize;
|
||||
background: #343737;
|
||||
}
|
||||
|
||||
.horizontal-splitter:hover,
|
||||
.horizontal-splitter.dragging {
|
||||
background: var(--cyan);
|
||||
}
|
||||
|
||||
.detail-pane {
|
||||
display: grid;
|
||||
grid-template-rows: 38px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #1b1d1d;
|
||||
}
|
||||
|
||||
.selection-summary code {
|
||||
overflow: hidden;
|
||||
color: #aeb4b1;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge,
|
||||
.status-badge,
|
||||
.frame-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 7px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
border-color: #34667a;
|
||||
color: #74c8e8;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
border-color: #3f7157;
|
||||
color: #83d5a5;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 36px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.payload-panel:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #202222;
|
||||
}
|
||||
|
||||
.panel-header > strong {
|
||||
padding: 0 10px;
|
||||
color: #c9cdca;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
min-width: 46px;
|
||||
border: 0;
|
||||
padding: 0 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: #d7dad8;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: #71c7e2;
|
||||
}
|
||||
|
||||
.tabs button.active::after {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 0;
|
||||
left: 8px;
|
||||
height: 2px;
|
||||
background: var(--cyan);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
margin-right: 7px;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #181a1a;
|
||||
}
|
||||
|
||||
.code-view,
|
||||
.hex-view {
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 14px 30px;
|
||||
color: #ccd1ce;
|
||||
font: 11px/1.55 var(--mono);
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.hex-view {
|
||||
color: #b6c2bd;
|
||||
}
|
||||
|
||||
.headers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: 11px/1.4 var(--mono);
|
||||
}
|
||||
|
||||
.headers-table th,
|
||||
.headers-table td {
|
||||
border-bottom: 1px solid #292c2c;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.headers-table th {
|
||||
width: 38%;
|
||||
color: #62b3cd;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.headers-table td {
|
||||
color: #c5c9c6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.frame-list {
|
||||
min-width: 480px;
|
||||
}
|
||||
|
||||
.frame-item {
|
||||
border-bottom: 1px solid #292c2c;
|
||||
}
|
||||
|
||||
.frame-item summary {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #c5cac7;
|
||||
cursor: pointer;
|
||||
font: 11px var(--mono);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.frame-item summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.frame-item summary:hover {
|
||||
background: #222525;
|
||||
}
|
||||
|
||||
.frame-item[open] summary {
|
||||
background: #242727;
|
||||
}
|
||||
|
||||
.frame-index {
|
||||
color: #747b77;
|
||||
}
|
||||
|
||||
.frame-kind {
|
||||
overflow: hidden;
|
||||
color: #dfaa85;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.frame-size,
|
||||
.frame-flags {
|
||||
color: #7faeb7;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.frame-error {
|
||||
margin: 10px 14px;
|
||||
color: #ec968e;
|
||||
font: 11px/1.5 var(--mono);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 14px;
|
||||
color: #7d8581;
|
||||
font: 12px/1.6 var(--mono);
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
color: #df8b83;
|
||||
}
|
||||
|
||||
.truncated-notice {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid #674f3f;
|
||||
background: #3d3028;
|
||||
color: #e5b28e;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 0 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: #848b87;
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
|
||||
.statusbar span:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 48px 84px minmax(0, 1fr) 26px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.url-filter,
|
||||
.request-id-filter {
|
||||
flex: 1 1 300px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.runtime-status code,
|
||||
.kind-column,
|
||||
.request-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.payload-panel {
|
||||
min-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 82px 122px minmax(0, 1fr) 26px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
order: 3;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime-status code {
|
||||
display: block;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-actions .button {
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.url-filter,
|
||||
.request-id-filter {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
order: 5;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#endpoint-filter {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sort-control {
|
||||
order: 4;
|
||||
flex: 0 0 104px;
|
||||
}
|
||||
|
||||
.segmented-control button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
|
||||
}
|
||||
|
||||
.request-table th,
|
||||
.request-table td {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.request-table .index-column,
|
||||
.request-table th:nth-child(2),
|
||||
.request-table td:nth-child(2),
|
||||
.size-column,
|
||||
.request-table th:nth-child(8),
|
||||
.request-table td:nth-child(8),
|
||||
.time-column,
|
||||
.request-table th:nth-child(9),
|
||||
.request-table td:nth-child(9) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.request-id-column {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.method-column {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.code-column {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
|
||||
}
|
||||
|
||||
.panel-header > strong {
|
||||
width: 72px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
min-width: 42px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 42px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.frame-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.frame-item summary {
|
||||
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#target-host {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
"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",
|
||||
"build:dev": "node ./scripts/run-vite-build.mjs --scan --minify false --mode development",
|
||||
"build": "node ./scripts/run-vite-build.mjs --scan --mode production",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,6 +17,7 @@
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.22",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.3"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { normalizePath } from "vite";
|
||||
const traverse = traverseModule.default ?? traverseModule;
|
||||
|
||||
const SOURCE_LANGUAGE = "zh-CN";
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP"];
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP", "ru-RU"];
|
||||
const HAN_REGEX = /\p{Script=Han}/u;
|
||||
const JS_HELPERS = {
|
||||
localized: "__i18nLocalized",
|
||||
@@ -490,22 +490,34 @@ function walkTemplateNode(node, visitor) {
|
||||
}
|
||||
}
|
||||
|
||||
function transformVueTemplate(templateCode, filePath, rootDir) {
|
||||
function resolveAbsoluteLoc(sourceCode, sourceOffset, relativeOffset) {
|
||||
const absoluteOffset = sourceOffset + relativeOffset;
|
||||
const prefix = sourceCode.slice(0, absoluteOffset);
|
||||
const lastLineBreak = prefix.lastIndexOf("\n");
|
||||
return {
|
||||
line: prefix.split("\n").length,
|
||||
column: absoluteOffset - lastLineBreak,
|
||||
};
|
||||
}
|
||||
|
||||
function transformVueTemplate(templateCode, filePath, rootDir, options = {}) {
|
||||
const ast = parseTemplate(templateCode, { comments: true });
|
||||
const replacements = [];
|
||||
const records = [];
|
||||
const sourceCode = options.sourceCode ?? templateCode;
|
||||
const sourceOffset = options.sourceOffset ?? 0;
|
||||
const resolveLoc = (node, offset = 0) =>
|
||||
resolveAbsoluteLoc(sourceCode, sourceOffset, node.loc.start.offset + offset);
|
||||
|
||||
walkTemplateNode(ast, (node, parent) => {
|
||||
if (node.type === 2 && containsHan(node.content)) {
|
||||
const canonical = node.content.trim();
|
||||
const record = buildMessageRecord(
|
||||
filePath,
|
||||
rootDir,
|
||||
node.content.trim(),
|
||||
canonical,
|
||||
0,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
resolveLoc(node, node.content.indexOf(canonical)),
|
||||
);
|
||||
const replacement = createTextNodeReplacement(node.loc.source, record);
|
||||
if (!replacement) {
|
||||
@@ -527,10 +539,7 @@ function transformVueTemplate(templateCode, filePath, rootDir) {
|
||||
rootDir,
|
||||
node.value.content,
|
||||
0,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
resolveLoc(node),
|
||||
);
|
||||
records.push(record);
|
||||
replacements.push({
|
||||
@@ -552,10 +561,7 @@ function transformVueTemplate(templateCode, filePath, rootDir) {
|
||||
node.content,
|
||||
filePath,
|
||||
rootDir,
|
||||
{
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
},
|
||||
resolveLoc(node),
|
||||
);
|
||||
if (!transformed.changed) {
|
||||
return;
|
||||
@@ -585,7 +591,10 @@ function transformVueSFC(code, filePath, rootDir) {
|
||||
let changed = false;
|
||||
|
||||
if (descriptor.template) {
|
||||
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir);
|
||||
const templateResult = transformVueTemplate(descriptor.template.content, filePath, rootDir, {
|
||||
sourceCode: code,
|
||||
sourceOffset: descriptor.template.loc.start.offset,
|
||||
});
|
||||
records.push(...templateResult.records);
|
||||
if (templateResult.changed) {
|
||||
changed = true;
|
||||
@@ -651,7 +660,7 @@ function collectCatalogRecords(rootDir) {
|
||||
return records;
|
||||
}
|
||||
|
||||
function syncCatalogFiles(rootDir) {
|
||||
export function syncCatalogFiles(rootDir) {
|
||||
const records = collectCatalogRecords(rootDir);
|
||||
const catalog = mergeMessageRecords(records);
|
||||
const generatedDir = path.join(rootDir, "src/i18n/generated");
|
||||
|
||||
@@ -196,6 +196,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
v-show="visible"
|
||||
class="modal-mask-layer fixed inset-0 z-999 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="closeAd"
|
||||
>
|
||||
<Transition name="ad-frame">
|
||||
<iframe
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Card from "@/components/ui/Card.vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import {
|
||||
disconnectCursorAccount,
|
||||
getCursorAccountStatus,
|
||||
startCursorAccountLogin,
|
||||
} from "@/services/clientApi";
|
||||
import { toUserError } from "@/state/appState";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
const CURSOR_ACCOUNT_CONTRIBUTOR_URL = "https://github.com/aike0210";
|
||||
const message = useMessage();
|
||||
|
||||
const cursorAccountStatus = ref({
|
||||
state: "signed_out",
|
||||
authId: "",
|
||||
email: "",
|
||||
error: "",
|
||||
});
|
||||
const cursorAccountBusy = ref(false);
|
||||
let cursorAccountTimer = null;
|
||||
|
||||
function maskCursorAccountIdentifier(value) {
|
||||
const identifier = String(value || "").trim();
|
||||
if (!identifier) return "";
|
||||
|
||||
const atIndex = identifier.indexOf("@");
|
||||
if (atIndex > 0 && atIndex < identifier.length - 1) {
|
||||
const localPart = identifier.slice(0, atIndex);
|
||||
const domain = identifier.slice(atIndex + 1);
|
||||
const maskedLocalPart = localPart.length <= 2
|
||||
? `${localPart[0]}***`
|
||||
: `${localPart[0]}***${localPart.at(-1)}`;
|
||||
return `${maskedLocalPart}@${domain}`;
|
||||
}
|
||||
|
||||
if (identifier.length <= 8) return "****";
|
||||
return `${identifier.slice(0, 4)}****${identifier.slice(-4)}`;
|
||||
}
|
||||
|
||||
const cursorAccountSignedIn = computed(
|
||||
() => cursorAccountStatus.value.state === "signed_in",
|
||||
);
|
||||
const cursorAccountWaiting = computed(
|
||||
() => cursorAccountStatus.value.state === "waiting",
|
||||
);
|
||||
const cursorAccountDisplayIdentifier = computed(() => {
|
||||
if (!cursorAccountSignedIn.value) return "";
|
||||
return maskCursorAccountIdentifier(
|
||||
cursorAccountStatus.value.email || cursorAccountStatus.value.authId,
|
||||
);
|
||||
});
|
||||
const cursorAccountStateText = computed(() => {
|
||||
if (cursorAccountSignedIn.value) return "已经登录";
|
||||
if (cursorAccountWaiting.value) return "等待浏览器登录";
|
||||
return "未连接";
|
||||
});
|
||||
|
||||
function showActionError(title, error) {
|
||||
const detail = String(error || "服务错误").trim() || "服务错误";
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
async function handleOpenContributor() {
|
||||
try {
|
||||
await Browser.OpenURL(CURSOR_ACCOUNT_CONTRIBUTOR_URL);
|
||||
} catch (error) {
|
||||
showActionError("打开贡献者主页失败", toUserError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCursorAccountStatus() {
|
||||
cursorAccountStatus.value = await getCursorAccountStatus();
|
||||
}
|
||||
|
||||
async function handleCursorAccountLogin() {
|
||||
cursorAccountBusy.value = true;
|
||||
try {
|
||||
cursorAccountStatus.value = await startCursorAccountLogin();
|
||||
} catch (error) {
|
||||
showActionError("登录失败", toUserError(error));
|
||||
await refreshCursorAccountStatus().catch(() => {});
|
||||
} finally {
|
||||
cursorAccountBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCursorAccountDisconnect() {
|
||||
const confirmed = await showModal({
|
||||
title: "退出登录",
|
||||
content: "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
||||
confirmText: "退出登录",
|
||||
cancelText: "取消",
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
cursorAccountBusy.value = true;
|
||||
try {
|
||||
cursorAccountStatus.value = await disconnectCursorAccount();
|
||||
} catch (error) {
|
||||
showActionError("退出登录失败", toUserError(error));
|
||||
} finally {
|
||||
cursorAccountBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCursorAccountStatus().catch(() => {});
|
||||
cursorAccountTimer = window.setInterval(() => {
|
||||
if (cursorAccountWaiting.value) {
|
||||
void refreshCursorAccountStatus().catch(() => {});
|
||||
}
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cursorAccountTimer) {
|
||||
window.clearInterval(cursorAccountTimer);
|
||||
cursorAccountTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 class="text-base font-medium text-white">Cursor 控制面账号</h2>
|
||||
<span
|
||||
class="rounded-full border border-[#3a3a3a] bg-[#202020] px-2 py-0.5 text-xs text-[#b8b8b8]"
|
||||
>
|
||||
{{ cursorAccountStateText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1 text-xs text-[#737373]">
|
||||
<span>@aike0210</span>
|
||||
<Tooltip>
|
||||
<div class="flex min-w-[220px] flex-col gap-2">
|
||||
<div>感谢 @aike0210 对 Cursor 控制面账号功能的贡献。</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-left text-[#8ab4f8] transition-colors duration-150 hover:text-[#b6d0fb]"
|
||||
@click="handleOpenContributor"
|
||||
>
|
||||
<span class="icon-[mdi--github] text-[14px]"></span>
|
||||
<span>github.com/aike0210</span>
|
||||
<span class="icon-[mdi--open-in-new] text-[12px]"></span>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
v-if="cursorAccountDisplayIdentifier"
|
||||
class="truncate text-sm text-[#d0d0d0]"
|
||||
>
|
||||
{{ cursorAccountDisplayIdentifier }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-[#a3a3a3]">
|
||||
独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号
|
||||
</div>
|
||||
<div v-if="cursorAccountWaiting" class="mt-1 text-sm text-[#d6a84b]">
|
||||
请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场
|
||||
</div>
|
||||
<div
|
||||
v-if="cursorAccountStatus.error"
|
||||
class="mt-1 break-all text-sm text-[#e06c75]"
|
||||
>
|
||||
{{ cursorAccountStatus.error }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="cursorAccountSignedIn"
|
||||
class="shrink-0"
|
||||
:disabled="cursorAccountBusy"
|
||||
@click="handleCursorAccountDisconnect"
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
class="shrink-0"
|
||||
variant="primary"
|
||||
:disabled="cursorAccountBusy || cursorAccountWaiting"
|
||||
@click="handleCursorAccountLogin"
|
||||
>
|
||||
{{ cursorAccountWaiting ? "等待登录..." : "登录 Cursor" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -273,7 +273,6 @@ const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
|
||||
<div
|
||||
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
|
||||
>
|
||||
<span>刷新统计</span>
|
||||
<button
|
||||
type="button"
|
||||
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createEmptyModelAdapter,
|
||||
normalizeModelAdapter,
|
||||
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
|
||||
OPENAI_ENDPOINT_CUSTOM,
|
||||
OPENAI_ENDPOINT_RESPONSES,
|
||||
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
} from "@/state/appState";
|
||||
@@ -18,10 +19,12 @@ const modelTypeOptions = [
|
||||
];
|
||||
|
||||
const reasoningEffortOptions = [
|
||||
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||
{ label: "最高", value: "max", icon: "icon-[mdi--brain]" },
|
||||
];
|
||||
|
||||
const anthropicThinkingEffortOptions = [
|
||||
@@ -35,10 +38,11 @@ const anthropicThinkingEffortOptions = [
|
||||
const openAIEndpointOptions = [
|
||||
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
|
||||
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
|
||||
{ label: "自定义路径", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
|
||||
];
|
||||
|
||||
const fieldTips = {
|
||||
openAIExtraParams: "开启后会把 JSON 对象合并到 OpenAI 请求体。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
@@ -48,7 +52,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => createEmptyModelAdapter(),
|
||||
},
|
||||
errorMessage: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["cancel", "save"]);
|
||||
@@ -283,13 +286,6 @@ function handleSave() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="mt-4 rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<Button variant="default" @click="handleCancel">取消</Button>
|
||||
<Button variant="primary" @click="handleSave">保存</Button>
|
||||
|
||||
@@ -44,7 +44,7 @@ const summaryText = computed(() => {
|
||||
return "测试中...";
|
||||
}
|
||||
if (normalizedStatus.value === "error") {
|
||||
return "测试失败";
|
||||
return "测试失败,请查看原始信息";
|
||||
}
|
||||
return props.emptyText;
|
||||
});
|
||||
@@ -100,16 +100,16 @@ const summaryClass = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[8px] border px-3 py-3" :class="panelClass">
|
||||
<div class="rounded-[8px] border-none px-3 py-3" :class="panelClass">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-[#666]' : 'text-sm font-medium text-white'"
|
||||
:class="compact ? 'text-[11px] uppercase tracking-[0.08em] text-white/80' : 'text-[11px] uppercase tracking-[0.08em] text-white/80'"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-[#8f8f8f]">
|
||||
<div v-if="rawResponseText" class="center-row gap-1 text-[11px] text-white/80">
|
||||
<span>原始返回</span>
|
||||
<Tooltip :content="rawResponseText" copyable />
|
||||
</div>
|
||||
@@ -118,12 +118,7 @@ const summaryClass = computed(() => {
|
||||
{{ summaryText }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="stale"
|
||||
class="shrink-0 rounded-[999px] border border-[#8a6d1a] px-2 py-1 text-xs text-[#f6d77a]"
|
||||
>
|
||||
需重测
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="stale" class="mt-2 text-xs text-[#f6d77a]">
|
||||
@@ -134,19 +129,19 @@ const summaryClass = computed(() => {
|
||||
v-if="showMetrics && normalizedStatus === 'success'"
|
||||
class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
>
|
||||
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||
<div class="rounded-[8px] bg-black/20 px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">总耗时</div>
|
||||
<div class="mt-1 text-sm text-[#d4d4d4]">{{ formatDuration(result?.totalDurationMS) }}</div>
|
||||
<div class="mt-1 text-sm text-white/80">{{ formatDuration(result?.totalDurationMS) }}</div>
|
||||
</div>
|
||||
<div class="rounded-[8px] bg-[#1c1c1c] px-3 py-2">
|
||||
<div class="rounded-[8px] bg-black/20 px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">输出 Token</div>
|
||||
<div class="mt-1 text-sm text-[#d4d4d4]">{{ result?.outputTokens ?? 0 }}</div>
|
||||
<div class="mt-1 text-sm text-white/80">{{ result?.outputTokens ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="normalizedStatus === 'success' && result?.tokensEstimated"
|
||||
class="mt-2 text-xs text-[#8f8f8f]"
|
||||
class="mt-2 text-xs text-white/80"
|
||||
>
|
||||
输出 Token 为估算值
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Combobox from "@/components/ui/Combobox.vue";
|
||||
import Input from "@/components/ui/Input.vue";
|
||||
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
|
||||
import Select from "@/components/ui/Select.vue";
|
||||
import Tooltip from "@/components/ui/Tooltip.vue";
|
||||
import { getModelEditorContext } from "@/services/clientApi";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import {
|
||||
ANTHROPIC_THINKING_EFFORT_DEFAULT,
|
||||
appState,
|
||||
@@ -12,11 +13,13 @@ import {
|
||||
createEmptyModelAdapter,
|
||||
CUSTOM_HEADERS_DEFAULT_JSON,
|
||||
EXTRA_PARAMS_DEFAULT_JSON,
|
||||
fetchAvailableModelIDs,
|
||||
getModelAdapterTestResult,
|
||||
getModelAdapterTestResultByID,
|
||||
isModelAdapterTestResultStale,
|
||||
normalizeModelAdapter,
|
||||
OPENAI_ENDPOINT_CHAT_COMPLETIONS,
|
||||
OPENAI_ENDPOINT_CUSTOM,
|
||||
OPENAI_ENDPOINT_RESPONSES,
|
||||
OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
runModelAdapterTest,
|
||||
@@ -24,8 +27,7 @@ import {
|
||||
toUserError,
|
||||
validateModelAdapters,
|
||||
} from "@/state/appState";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
|
||||
const modelTypeTabs = [
|
||||
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
|
||||
@@ -33,10 +35,12 @@ const modelTypeTabs = [
|
||||
];
|
||||
|
||||
const reasoningEffortOptions = [
|
||||
{ label: "不设置", value: "", icon: "icon-[mdi--minus-circle-outline]" },
|
||||
{ label: "低", value: "low", icon: "icon-[mdi--head-outline]" },
|
||||
{ label: "中", value: "medium", icon: "icon-[mdi--head-lightbulb-outline]" },
|
||||
{ label: "高", value: "high", icon: "icon-[mdi--brain]" },
|
||||
{ label: "极高", value: "xhigh", icon: "icon-[mdi--head-cog-outline]" },
|
||||
{ label: "最高", value: "max", icon: "icon-[mdi--brain]" },
|
||||
];
|
||||
|
||||
const anthropicThinkingEffortOptions = [
|
||||
@@ -50,14 +54,28 @@ const anthropicThinkingEffortOptions = [
|
||||
const openAIEndpointOptions = [
|
||||
{ label: "/v1/responses", value: OPENAI_ENDPOINT_RESPONSES, icon: "icon-[mdi--api]" },
|
||||
{ label: "/v1/chat/completions", value: OPENAI_ENDPOINT_CHAT_COMPLETIONS, icon: "icon-[mdi--message-text-outline]" },
|
||||
{ label: "自定义路径(请输入完整请求地址)", value: OPENAI_ENDPOINT_CUSTOM, icon: "icon-[mdi--pencil-outline]" },
|
||||
];
|
||||
|
||||
const editorIndex = ref(-1);
|
||||
const draft = reactive(createEmptyModelAdapter());
|
||||
const errorMessage = ref("");
|
||||
const loading = ref(true);
|
||||
const props = defineProps({
|
||||
index: { type: Number, default: -1 },
|
||||
adapter: { type: Object, default: () => createEmptyModelAdapter() },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
const message = useMessage();
|
||||
|
||||
const editorIndex = ref(props.index);
|
||||
const draft = reactive(normalizeModelAdapter(props.adapter));
|
||||
if (!draft.type) {
|
||||
draft.type = "openai";
|
||||
}
|
||||
const lastTestAdapterID = ref("");
|
||||
const localTestFailure = ref("");
|
||||
const availableModelIDs = ref(draft.modelID ? [draft.modelID] : []);
|
||||
const modelListLoading = ref(false);
|
||||
const modelListRequestSeq = ref(0);
|
||||
let modelListDebounceTimer = 0;
|
||||
|
||||
function createOptionalPositiveIntegerModel(key) {
|
||||
return computed({
|
||||
@@ -77,14 +95,23 @@ const contextWindowTokensInput = createOptionalPositiveIntegerModel("contextWind
|
||||
const interfacePlaceholder = computed(() =>
|
||||
draft.type === "anthropic" ? "例如:https://api.anthropic.com" : "例如:https://api.openai.com/v1",
|
||||
);
|
||||
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(draft));
|
||||
const directModelTestResult = computed(() => getModelAdapterTestResult(draft));
|
||||
const modelOptions = computed(() => availableModelIDs.value.map((modelID) => ({
|
||||
label: modelID,
|
||||
value: modelID,
|
||||
icon: "icon-[mdi--cube-outline]",
|
||||
})));
|
||||
const canFetchModels = computed(() => Boolean(
|
||||
draft.type && String(draft.baseURL || "").trim() && String(draft.apiKey || "").trim(),
|
||||
));
|
||||
const selectedTestAdapter = computed(() => normalizeModelAdapter(draft));
|
||||
const currentRequestHash = computed(() => buildModelAdapterTestRequestHash(selectedTestAdapter.value));
|
||||
const directModelTestResult = computed(() => getModelAdapterTestResult(selectedTestAdapter.value));
|
||||
const rememberedModelTestResult = computed(() =>
|
||||
lastTestAdapterID.value ? getModelAdapterTestResultByID(lastTestAdapterID.value) : null,
|
||||
);
|
||||
const activeModelTestResult = computed(() => directModelTestResult.value || rememberedModelTestResult.value);
|
||||
const modelTestResultStale = computed(() =>
|
||||
isModelAdapterTestResultStale(draft, activeModelTestResult.value),
|
||||
isModelAdapterTestResultStale(selectedTestAdapter.value, activeModelTestResult.value),
|
||||
);
|
||||
const isCurrentConfigTesting = computed(() => directModelTestResult.value?.status === "running");
|
||||
const modelTestSummary = computed(() => {
|
||||
@@ -94,8 +121,6 @@ const modelTestSummary = computed(() => {
|
||||
return activeModelTestResult.value?.summaryText || "尚未测试";
|
||||
});
|
||||
|
||||
const title = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
|
||||
|
||||
function ensureOpenAIExtraParamsJSON() {
|
||||
if (!String(draft.openAIExtraParamsJSON || "").trim()) {
|
||||
draft.openAIExtraParamsJSON = OPENAI_EXTRA_PARAMS_DEFAULT_JSON;
|
||||
@@ -122,13 +147,13 @@ function ensureAnthropicThinkingEffort() {
|
||||
|
||||
const fieldTips = {
|
||||
displayName: "仅用于界面展示,便于你区分不同模型。",
|
||||
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
|
||||
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
apiKey: "调用该模型服务需要使用的访问密钥。",
|
||||
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
reasoningEffort: "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
|
||||
reasoningEffort: "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
|
||||
maxCompletionTokens: "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
openAIEndpoint: "OpenAI 兼容接口使用的协议端点。未选择时默认使用 /v1/responses。",
|
||||
openAIEndpoint: "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
||||
openAIExtraParams: "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
|
||||
customHeaders: "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
||||
anthropicExtraParams: "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
|
||||
@@ -137,20 +162,42 @@ const fieldTips = {
|
||||
tooltipData: "模型列表 hover 时显示的备注说明。",
|
||||
};
|
||||
|
||||
async function loadContext() {
|
||||
try {
|
||||
const ctx = await getModelEditorContext();
|
||||
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
|
||||
const parsed = JSON.parse(ctx.adapterJSON || "{}");
|
||||
Object.assign(draft, normalizeModelAdapter(parsed));
|
||||
if (!draft.type) {
|
||||
draft.type = "openai";
|
||||
async function refreshModelList() {
|
||||
const baseURL = String(draft.baseURL || "").trim();
|
||||
const apiKey = String(draft.apiKey || "").trim();
|
||||
if (!baseURL || !apiKey || !draft.type) {
|
||||
modelListRequestSeq.value += 1;
|
||||
availableModelIDs.value = [];
|
||||
modelListLoading.value = false;
|
||||
return [];
|
||||
}
|
||||
|
||||
const requestSeq = modelListRequestSeq.value + 1;
|
||||
modelListRequestSeq.value = requestSeq;
|
||||
modelListLoading.value = true;
|
||||
availableModelIDs.value = [];
|
||||
try {
|
||||
const models = await fetchAvailableModelIDs({
|
||||
type: draft.type,
|
||||
baseURL,
|
||||
apiKey,
|
||||
customHeadersEnabled: draft.customHeadersEnabled,
|
||||
customHeadersJSON: draft.customHeadersJSON,
|
||||
});
|
||||
if (requestSeq !== modelListRequestSeq.value) {
|
||||
return availableModelIDs.value;
|
||||
}
|
||||
availableModelIDs.value = models;
|
||||
return models;
|
||||
} catch (_error) {
|
||||
Object.assign(draft, createEmptyModelAdapter());
|
||||
draft.type = "openai";
|
||||
if (requestSeq === modelListRequestSeq.value) {
|
||||
availableModelIDs.value = [];
|
||||
}
|
||||
return availableModelIDs.value;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestSeq === modelListRequestSeq.value) {
|
||||
modelListLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,13 +206,13 @@ async function persistDraft() {
|
||||
|
||||
const singleCheck = validateModelAdapters([adapter]);
|
||||
if (singleCheck) {
|
||||
errorMessage.value = singleCheck;
|
||||
message(singleCheck);
|
||||
return { ok: false, error: singleCheck, adapter: null };
|
||||
}
|
||||
|
||||
const result = await saveModelAdapterAt(editorIndex.value, adapter);
|
||||
if (!result.ok) {
|
||||
errorMessage.value = result.error;
|
||||
message(result.error);
|
||||
return { ok: false, error: result.error, adapter: null };
|
||||
}
|
||||
|
||||
@@ -174,12 +221,13 @@ async function persistDraft() {
|
||||
}
|
||||
if (result.adapter) {
|
||||
Object.assign(draft, normalizeModelAdapter(result.adapter));
|
||||
} else {
|
||||
Object.assign(draft, adapter);
|
||||
}
|
||||
errorMessage.value = "";
|
||||
return {
|
||||
ok: true,
|
||||
error: "",
|
||||
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(draft),
|
||||
adapter: result.adapter ? normalizeModelAdapter(result.adapter) : normalizeModelAdapter(adapter),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,15 +236,20 @@ async function handleSave() {
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
await Window.Close();
|
||||
emit("saved", result.adapter);
|
||||
emit("close");
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
await Window.Close();
|
||||
function handleCancel() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleModelTypeChange(type) {
|
||||
draft.type = type;
|
||||
modelListRequestSeq.value += 1;
|
||||
modelListLoading.value = false;
|
||||
availableModelIDs.value = [];
|
||||
draft.modelID = "";
|
||||
if (type === "openai" && !draft.openAIEndpoint) {
|
||||
draft.openAIEndpoint = OPENAI_ENDPOINT_RESPONSES;
|
||||
} else if (type === "anthropic") {
|
||||
@@ -270,31 +323,40 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContext();
|
||||
watch(
|
||||
() => [draft.type, draft.baseURL, draft.apiKey, draft.customHeadersEnabled, draft.customHeadersJSON],
|
||||
() => {
|
||||
window.clearTimeout(modelListDebounceTimer);
|
||||
const baseURL = String(draft.baseURL || "").trim();
|
||||
const apiKey = String(draft.apiKey || "").trim();
|
||||
if (!baseURL || !apiKey) {
|
||||
modelListRequestSeq.value += 1;
|
||||
modelListLoading.value = false;
|
||||
availableModelIDs.value = [];
|
||||
return;
|
||||
}
|
||||
modelListDebounceTimer = window.setTimeout(() => {
|
||||
void refreshModelList();
|
||||
}, 600);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(modelListDebounceTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col text-[#e5e5e5]">
|
||||
<div class="flex shrink-0 items-center justify-between px-4 pb-2">
|
||||
<h2 class="text-base font-medium text-white">{{ title }}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="default" @click="handleCancel">取消</Button>
|
||||
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
|
||||
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
|
||||
</Button>
|
||||
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
|
||||
{{ appState.configSaving ? "保存中..." : "保存" }}
|
||||
</Button>
|
||||
<div class="flex-shrink-0 p-4" v-if="localTestFailure || activeModelTestResult">
|
||||
<ModelAdapterTestCard
|
||||
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
|
||||
:stale="modelTestResultStale"
|
||||
:show-metrics="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-sm text-[#a3a3a3]">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto min-h-0 px-4 pb-4">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 scroll-shadow-bottom">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="center-row gap-2">
|
||||
<button
|
||||
@@ -315,26 +377,13 @@ onMounted(async () => {
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.displayName" />
|
||||
<span>显示名称</span>
|
||||
<Tooltip :content="fieldTips.baseURL" />
|
||||
<span>接口地址</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.displayName"
|
||||
v-model="draft.baseURL"
|
||||
type="text"
|
||||
placeholder="例如:OpenAI - GPT-4.1"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.modelID" />
|
||||
<span>模型标识</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.modelID"
|
||||
type="text"
|
||||
placeholder="例如:gpt-4.1"
|
||||
:placeholder="interfacePlaceholder"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
@@ -355,17 +404,43 @@ onMounted(async () => {
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.baseURL" />
|
||||
<span>接口地址</span>
|
||||
<Tooltip :content="fieldTips.displayName" />
|
||||
<span>显示名称</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="draft.baseURL"
|
||||
v-model="draft.displayName"
|
||||
type="text"
|
||||
:placeholder="interfacePlaceholder"
|
||||
placeholder="例如:GPT-5"
|
||||
class="h-9 rounded-[6px] border border-[#3f3f3f] bg-[#232323] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#10AD5D]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.modelID" />
|
||||
<span>模型标识</span>
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="draft.modelID"
|
||||
:options="modelOptions"
|
||||
:loading="modelListLoading"
|
||||
placeholder="例如:gpt-4.1"
|
||||
empty-text="没有匹配的模型"
|
||||
aria-label="选择模型"
|
||||
>
|
||||
<template #append>
|
||||
<button
|
||||
type="button"
|
||||
class="center-row h-9 shrink-0 gap-1.5 whitespace-nowrap rounded-[6px] border border-[#3f3f3f] bg-[#292929] px-[8px] text-sm text-[#d4d4d4] outline-none transition-colors hover:border-[#505050] hover:bg-[#303030] hover:text-white focus-visible:border-[#10AD5D] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="modelListLoading || !canFetchModels"
|
||||
@click="refreshModelList"
|
||||
>
|
||||
<span>获取模型</span>
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="center-row justify-start gap-1.5 text-sm text-[#d4d4d4]">
|
||||
<Tooltip :content="fieldTips.contextWindowTokens" />
|
||||
@@ -530,19 +605,16 @@ onMounted(async () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<ModelAdapterTestCard
|
||||
:result="localTestFailure ? { status: 'error', error: '测试失败', summaryText: '测试失败', rawResponse: modelTestSummary } : activeModelTestResult"
|
||||
:stale="modelTestResultStale"
|
||||
:show-metrics="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 px-4 py-3">
|
||||
<Button variant="default" :disabled="appState.configSaving" @click="handleCancel">取消</Button>
|
||||
<Button variant="default" :disabled="isCurrentConfigTesting || appState.configSaving" @click="handleTest">
|
||||
{{ isCurrentConfigTesting ? "测试中..." : "保存并测试" }}
|
||||
</Button>
|
||||
<Button variant="primary" :disabled="appState.configSaving" @click="handleSave">
|
||||
{{ appState.configSaving ? "保存中..." : "保存" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup></script>
|
||||
<template>
|
||||
<div
|
||||
class="rounded-[8px] p-[1px]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
class="rounded-[8px] border border-[#343434] bg-[#292929] p-4 "
|
||||
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup>
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useId, watch, watchPostEffect } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: "" },
|
||||
emptyText: { type: String, default: "没有匹配项" },
|
||||
loading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "change", "blur"]);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const inputRef = ref(null);
|
||||
const menuRef = ref(null);
|
||||
const optionRefs = ref([]);
|
||||
const isOpen = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
const showAllOptions = ref(false);
|
||||
const menuStyle = ref({});
|
||||
const listboxID = useId();
|
||||
|
||||
const normalizedOptions = computed(() => props.options.map((option, optionIndex) => {
|
||||
if (typeof option === "string") {
|
||||
return { label: option, value: option, icon: "", optionIndex };
|
||||
}
|
||||
return {
|
||||
label: option?.label ?? option?.value ?? "",
|
||||
value: option?.value ?? "",
|
||||
icon: option?.icon ?? option?.iconClass ?? "",
|
||||
optionIndex,
|
||||
};
|
||||
}));
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = String(props.modelValue || "").trim().toLocaleLowerCase();
|
||||
if (showAllOptions.value || !query) {
|
||||
return normalizedOptions.value;
|
||||
}
|
||||
return normalizedOptions.value.filter((option) => (
|
||||
String(option.label).toLocaleLowerCase().includes(query)
|
||||
|| String(option.value).toLocaleLowerCase().includes(query)
|
||||
));
|
||||
});
|
||||
const activeOption = computed(() => filteredOptions.value[activeIndex.value] ?? null);
|
||||
const activeDescendant = computed(() => (
|
||||
isOpen.value && activeOption.value ? `${listboxID}-option-${activeOption.value.optionIndex}` : undefined
|
||||
));
|
||||
|
||||
function setOptionRef(el, index) {
|
||||
if (el) {
|
||||
optionRefs.value[index] = el;
|
||||
return;
|
||||
}
|
||||
delete optionRefs.value[index];
|
||||
}
|
||||
|
||||
function focusActiveOption() {
|
||||
nextTick(() => optionRefs.value[activeOption.value?.optionIndex]?.scrollIntoView({ block: "nearest" }));
|
||||
}
|
||||
|
||||
function openMenu({ showAll = false } = {}) {
|
||||
if (props.disabled || normalizedOptions.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
showAllOptions.value = showAll;
|
||||
isOpen.value = true;
|
||||
const selectedIndex = filteredOptions.value.findIndex((option) => option.value === props.modelValue);
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : filteredOptions.value.length ? 0 : -1;
|
||||
nextTick(updatePosition);
|
||||
}
|
||||
|
||||
function closeMenu({ restoreFocus = false, notifyBlur = false } = {}) {
|
||||
if (!isOpen.value) {
|
||||
if (notifyBlur) {
|
||||
emit("blur");
|
||||
}
|
||||
return;
|
||||
}
|
||||
isOpen.value = false;
|
||||
activeIndex.value = -1;
|
||||
if (restoreFocus) {
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
}
|
||||
if (notifyBlur) {
|
||||
emit("blur");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
emit("update:modelValue", event.target.value);
|
||||
showAllOptions.value = false;
|
||||
isOpen.value = normalizedOptions.value.length > 0;
|
||||
activeIndex.value = filteredOptions.value.length ? 0 : -1;
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", option.value);
|
||||
emit("change", option.value);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (isOpen.value) {
|
||||
closeMenu({ restoreFocus: true });
|
||||
return;
|
||||
}
|
||||
openMenu({ showAll: true });
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
}
|
||||
|
||||
function moveActiveIndex(step) {
|
||||
if (!isOpen.value) {
|
||||
openMenu({ showAll: true });
|
||||
return;
|
||||
}
|
||||
const total = filteredOptions.value.length;
|
||||
if (!total) {
|
||||
return;
|
||||
}
|
||||
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
|
||||
activeIndex.value = (current + step + total) % total;
|
||||
focusActiveOption();
|
||||
}
|
||||
|
||||
function handleInputKeydown(event) {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(1);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
moveActiveIndex(-1);
|
||||
break;
|
||||
case "Enter":
|
||||
if (isOpen.value && activeIndex.value >= 0) {
|
||||
event.preventDefault();
|
||||
selectOption(filteredOptions.value[activeIndex.value]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (isOpen.value) {
|
||||
event.preventDefault();
|
||||
closeMenu({ restoreFocus: true });
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
closeMenu({ notifyBlur: true });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
closeMenu({ notifyBlur: true });
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
if (!rootRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
computePosition(rootRef.value, menuRef.value, {
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
offset(6),
|
||||
flip({ padding: 12 }),
|
||||
shift({ padding: 12 }),
|
||||
size({
|
||||
apply({ rects, elements, availableHeight }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
width: `${rects.reference.width}px`,
|
||||
maxHeight: `${Math.max(availableHeight, 160)}px`,
|
||||
});
|
||||
},
|
||||
padding: 12,
|
||||
}),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
menuStyle.value = { left: `${x}px`, top: `${y}px` };
|
||||
});
|
||||
}
|
||||
|
||||
watch(filteredOptions, (options) => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value = options.length ? Math.min(Math.max(activeIndex.value, 0), options.length - 1) : -1;
|
||||
nextTick(updatePosition);
|
||||
});
|
||||
|
||||
watch(normalizedOptions, (options) => {
|
||||
if (options.length === 0) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
watchPostEffect((cleanup) => {
|
||||
if (!isOpen.value || !rootRef.value || !menuRef.value) {
|
||||
return;
|
||||
}
|
||||
const stopAutoUpdate = autoUpdate(rootRef.value, menuRef.value, updatePosition);
|
||||
cleanup(stopAutoUpdate);
|
||||
});
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return;
|
||||
}
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
|
||||
watch(() => props.disabled, (disabled) => {
|
||||
if (disabled) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="flex h-9 min-w-0 flex-1 items-center rounded-[6px] border border-[#3f3f3f] bg-[#232323] transition-colors focus-within:border-[#10AD5D]"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="min-w-0 flex-1 bg-transparent px-3 text-sm text-[#e5e5e5] outline-none placeholder:text-[#7b7b7b] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxID"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
@focus="openMenu({ showAll: true })"
|
||||
@input="handleInput"
|
||||
@change="emit('change', $event.target.value)"
|
||||
@keydown="handleInputKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="center-row h-full w-9 shrink-0 text-[#8f8f8f] outline-none transition-colors hover:text-[#d4d4d4] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="ariaLabel || undefined"
|
||||
:aria-expanded="isOpen"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span v-if="loading" class="icon-[mdi--loading] animate-spin text-[17px]"></span>
|
||||
<span v-else class="icon-[mdi--chevron-down] text-[18px] transition-transform duration-200" :class="isOpen ? 'rotate-180' : ''"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="$slots.append" class="shrink-0">
|
||||
<slot name="append" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-1 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="isOpen && normalizedOptions.length"
|
||||
ref="menuRef"
|
||||
:id="listboxID"
|
||||
role="listbox"
|
||||
class="fixed z-[999] overflow-y-auto rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<ul v-show="filteredOptions.length" role="presentation" class="py-1">
|
||||
<li
|
||||
v-for="option in normalizedOptions"
|
||||
v-show="filteredOptions.includes(option)"
|
||||
:key="option.value"
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
:ref="(el) => setOptionRef(el, option.optionIndex)"
|
||||
:id="`${listboxID}-option-${option.optionIndex}`"
|
||||
type="button"
|
||||
role="option"
|
||||
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
|
||||
:class="[
|
||||
option.value === modelValue
|
||||
? 'bg-[#10AD5D]/15 text-[#10d06f]'
|
||||
: 'text-[#e5e5e5] hover:bg-[#303030]',
|
||||
activeOption === option ? 'bg-[#303030]' : '',
|
||||
]"
|
||||
:aria-selected="option.value === modelValue"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="selectOption(option)"
|
||||
@mouseenter="activeIndex = filteredOptions.indexOf(option)"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span v-if="option.icon" :class="[option.icon, 'shrink-0 text-[16px]']" aria-hidden="true"></span>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-show="!filteredOptions.length" class="px-3 py-2 text-sm text-[#8f8f8f]">{{ emptyText }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: "" },
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
validator: (value) => ["md", "lg", "xl"].includes(value),
|
||||
},
|
||||
closeOnBackdrop: { type: Boolean, default: true },
|
||||
closeOnEscape: { type: Boolean, default: true },
|
||||
closeDisabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const panelRef = ref(null);
|
||||
const closeButtonRef = ref(null);
|
||||
let previouslyFocusedElement = null;
|
||||
|
||||
const panelClass = computed(() => ({
|
||||
md: "max-w-[420px]",
|
||||
lg: "max-w-[680px]",
|
||||
xl: "h-full max-h-[760px] max-w-[880px]",
|
||||
}[props.size]));
|
||||
|
||||
function requestClose() {
|
||||
if (!props.closeDisabled) {
|
||||
emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
if (props.closeOnBackdrop) {
|
||||
requestClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === "Escape" && props.open && props.closeOnEscape) {
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !props.open || !panelRef.value) {
|
||||
return;
|
||||
}
|
||||
const focusable = Array.from(panelRef.value.querySelectorAll(
|
||||
"button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
));
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
previouslyFocusedElement = document.activeElement;
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
nextTick(() => {
|
||||
const firstField = panelRef.value?.querySelector(
|
||||
"input:not([disabled]), textarea:not([disabled]), select:not([disabled])",
|
||||
);
|
||||
(firstField || closeButtonRef.value)?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
if (previouslyFocusedElement instanceof HTMLElement && document.contains(previouslyFocusedElement)) {
|
||||
previouslyFocusedElement.focus();
|
||||
}
|
||||
previouslyFocusedElement = null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="open"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 px-7 pb-[38px] pt-[calc(48px+env(safe-area-inset-top))]"
|
||||
@click.self="handleBackdropClick"
|
||||
>
|
||||
<section
|
||||
ref="panelRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title || undefined"
|
||||
class="flex w-full max-h-[660px] min-h-0 flex-col overflow-hidden rounded-[8px] border border-[#3a3a3a] bg-[#202020] shadow-[0_24px_64px_rgba(0,0,0,0.65)]"
|
||||
:class="panelClass"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex h-12 shrink-0 items-center justify-between border-b border-[#343434] px-4">
|
||||
<h2 class="min-w-0 truncate text-base font-medium text-white">{{ title }}</h2>
|
||||
<button
|
||||
ref="closeButtonRef"
|
||||
type="button"
|
||||
class="center-row w-[28px] h-[28px] justify-center size-8 shrink-0 rounded-[6px] text-[#8f8f8f] outline-none transition-colors hover:bg-[#303030] hover:text-white focus-visible:ring-2 focus-visible:ring-[#10AD5D]/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="closeDisabled"
|
||||
aria-label="关闭"
|
||||
@click="requestClose"
|
||||
>
|
||||
<span class="icon-[mdi--close] text-[19px]"></span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -2,56 +2,24 @@
|
||||
import { messageState, provideMessage } from "@/composables/useMessage";
|
||||
|
||||
provideMessage();
|
||||
|
||||
const MESSAGE_THEME = {
|
||||
success: {
|
||||
containerClass: "bg-[#10AD5D] text-white",
|
||||
iconClass: "icon-[dashicons--yes]",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
error: {
|
||||
containerClass: "bg-[#D84C4C] text-white",
|
||||
iconClass: "",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
info: {
|
||||
containerClass: "bg-[#F08A24] text-white",
|
||||
iconClass: "",
|
||||
iconExtraClass: "",
|
||||
},
|
||||
loading: {
|
||||
containerClass: "bg-[#3a3a3a] text-white",
|
||||
iconClass: "icon-[mingcute--loading-fill]",
|
||||
iconExtraClass: "animate-spin",
|
||||
},
|
||||
};
|
||||
|
||||
function resolveTheme(type) {
|
||||
return MESSAGE_THEME[type] || MESSAGE_THEME.info;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4">
|
||||
<Teleport to="body">
|
||||
<div class="pointer-events-none fixed inset-x-0 top-[calc(48px+env(safe-area-inset-top))] z-[11000] flex justify-center px-4">
|
||||
<Transition name="message-slide" mode="out-in">
|
||||
<div
|
||||
v-if="messageState.current"
|
||||
:key="messageState.current.id"
|
||||
class="pointer-events-auto inline-flex max-w-full items-center gap-2 rounded-full px-4 py-2 text-sm shadow-[0_8px_24px_rgba(0,0,0,0.28)]"
|
||||
:class="resolveTheme(messageState.current.type).containerClass"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="max-w-[min(520px,calc(100vw-32px))] rounded-[8px] border border-[#454545] bg-[#2b2b2b] px-4 py-2.5 text-center text-sm leading-5 text-[#ededed] shadow-[0_10px_30px_rgba(0,0,0,0.38)]"
|
||||
>
|
||||
<span
|
||||
v-if="resolveTheme(messageState.current.type).iconClass"
|
||||
class="text-[14px]"
|
||||
:class="[
|
||||
resolveTheme(messageState.current.type).iconClass,
|
||||
resolveTheme(messageState.current.type).iconExtraClass,
|
||||
]"
|
||||
/>
|
||||
<span class="leading-none whitespace-nowrap">{{ messageState.current.content }}</span>
|
||||
{{ messageState.current.content }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -62,7 +30,7 @@ function resolveTheme(type) {
|
||||
|
||||
.message-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.message-slide-enter-to,
|
||||
@@ -73,11 +41,6 @@ function resolveTheme(type) {
|
||||
|
||||
.message-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-12px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,11 +39,10 @@ function onMaskClick() {
|
||||
<Transition name="modal-content">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)]"
|
||||
style="background: linear-gradient(to bottom, #656565 0%, #3A3A3A 10px, #3A3A3A 100%);"
|
||||
class="relative z-10 w-full max-w-[360px] overflow-hidden rounded-[8px] p-px shadow-[0_25px_50px_-12px_rgba(0,0,0,0.6)] border border-[#3A3A3A] bg-[#292929]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="rounded-[7px] bg-[#292929] p-5">
|
||||
<div class="rounded-[7px] p-5">
|
||||
<h3 class="mb-3 text-base font-medium text-white">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
@@ -117,7 +117,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-[#727272] transition-colors duration-150 hover:text-[#cfcfcf]"
|
||||
class="center-row h-[16px] w-[16px] cursor-help rounded-full text-white/60 transition-colors duration-150 hover:text-white/80"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="scheduleHideTooltip"
|
||||
@focus="showTooltip"
|
||||
|
||||
@@ -35,10 +35,9 @@ function removeMessage(id, options = {}) {
|
||||
messageState.current = null;
|
||||
}
|
||||
|
||||
function showMessage(options = {}) {
|
||||
const type = typeof options.type === "string" ? options.type : "info";
|
||||
const content = String(options.content || "").trim();
|
||||
if (!content) {
|
||||
function showMessage(content, options = {}) {
|
||||
const normalizedContent = String(content || "").trim();
|
||||
if (!normalizedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -48,14 +47,11 @@ function showMessage(options = {}) {
|
||||
|
||||
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,
|
||||
content: normalizedContent,
|
||||
shownAt: Date.now(),
|
||||
timer: null,
|
||||
};
|
||||
@@ -70,40 +66,24 @@ function showMessage(options = {}) {
|
||||
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() {
|
||||
export function message(content, options = {}) {
|
||||
return showMessage(content, options);
|
||||
}
|
||||
|
||||
message.remove = removeMessage;
|
||||
message.clear = () => {
|
||||
if (messageState.current) {
|
||||
removeMessage(messageState.current.id, { force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultMessageApi = createMessageApi();
|
||||
};
|
||||
|
||||
export function provideMessage() {
|
||||
provide(MESSAGE_API_SYMBOL, defaultMessageApi);
|
||||
return defaultMessageApi;
|
||||
provide(MESSAGE_API_SYMBOL, message);
|
||||
return message;
|
||||
}
|
||||
|
||||
export function useMessage() {
|
||||
return inject(MESSAGE_API_SYMBOL, defaultMessageApi);
|
||||
return inject(MESSAGE_API_SYMBOL, message);
|
||||
}
|
||||
|
||||
export { messageState, showMessage, removeMessage };
|
||||
@@ -2,9 +2,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 SUPPORTED_LOCALES = ["zh-CN", "en-US", "ja-JP", "ru-RU"];
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "English", value: "en-US" },
|
||||
{ label: "日本語", value: "ja-JP" },
|
||||
{ label: "Русский", value: "ru-RU" },
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,119 +1,148 @@
|
||||
{
|
||||
"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. 65536 (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",
|
||||
"09ebc2643631ba25": "Cost Estimate",
|
||||
"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",
|
||||
"0d6b7efd5ccefd8a": "You can configure model channels. Runtime logs are stored in",
|
||||
"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.",
|
||||
"185aebe19c77425d": "{0} must be a JSON object",
|
||||
"18b7312022cd1840": "Start Service",
|
||||
"1af38868896cf53d": "Routing mode only supports local or upstream",
|
||||
"1b7d24b212e52c54": "{0} reasoning effort only supports low, medium, high, and xhigh",
|
||||
"1afed6a81a2512d2": "Select model",
|
||||
"1baddde657dd2720": "Current outbound requests use system proxy",
|
||||
"1bc77f5ab979f4c1": "Add Model Settings",
|
||||
"1c631615c1d85c9e": "Log in to Cursor",
|
||||
"1e238093b79b3165": "Uses 65536 by default when left blank",
|
||||
"21296ab18ad9af25": "Extra Params JSON",
|
||||
"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",
|
||||
"3275d7e70bbdc3d5": "{0}'s {1}",
|
||||
"32b3c9a50003f77a": "Output tokens are estimated",
|
||||
"33d2d273e2bd5f88": "Failed to open user guide",
|
||||
"3463c5585c246df9": "Total: {0}",
|
||||
"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",
|
||||
"3ab8cc15939f3b5c": "Log out",
|
||||
"3af7e5489e61ea51": "Refreshing",
|
||||
"3bf8512aa520ed21": "Local Service Mode",
|
||||
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
|
||||
"3d13868593ae4eeb": "Interface Language",
|
||||
"3d52574ce1500561": "Not connected",
|
||||
"3ea83f9f55062582": "Release date: {0}",
|
||||
"3edda85621fd03b2": "model adapters",
|
||||
"3fd47edce45b3603": "Close",
|
||||
"42aa8e01e98c0d8c": "Total Duration",
|
||||
"438bb77c1ecdfab0": "Cache Write: {0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "Currently displayed using the reuse rate definition",
|
||||
"468adaa418ee1475": "e.g. https://api.openai.com/v1",
|
||||
"472642d58d3d5a6d": "Formula: {0}",
|
||||
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"4b8d11bf235e9213": "Current hit rate: {0}",
|
||||
"4b5e0ae1288a9695": "No matching models",
|
||||
"4c0a929bb86ce912": "Current: {0}",
|
||||
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Unknown",
|
||||
"51194c3ad014fb29": "Retest required",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
|
||||
"5205125c0e91d346": "Maximum tokens an Anthropic model may generate in a single response. Leave blank to use the default.",
|
||||
"54e6745ff43c9c74": "Sorting failed",
|
||||
"56627c94a9decee6": "Max Output Tokens",
|
||||
"58c6b0935a7216da": "Failed to open contributor profile",
|
||||
"593a972852ba0004": "Cursor Assistant | Permanently Free | Custom API",
|
||||
"59a2195a01a8b35b": "{0} must be a valid JSON object",
|
||||
"5aa8f5590c940829": "Non-cache Input: {0}",
|
||||
"5beb1206c532729f": "Maximum number of tokens allowed in a single response. Leave blank to use the default.",
|
||||
"5c55a67935af8f45": "All",
|
||||
"5d1687a4a41883fd": "Stopping...",
|
||||
"5e709712ce012f5d": "Current outbound requests use environment variable proxy: {0}",
|
||||
"6106f0a12583a334": "Refresh failed",
|
||||
"62873083fcaed27d": "Session Statistics",
|
||||
"6309a3bb5ba4c714": "Save failed",
|
||||
"636e3deffc1e960a": "Model {0}",
|
||||
"63d90d977348ab1f": "Duplicate",
|
||||
"64857b4338678314": "The value of custom header {0} must be a string",
|
||||
"64d2730f2ae37997": "Raw Response",
|
||||
"65cc5fd2e6ce6e75": "Backend started, proxy not started",
|
||||
"66af574b8948fe83": "{0} API key cannot be empty",
|
||||
"6744b4c6a9aa0038": "Disabled",
|
||||
"675109292da4eb36": "Not tested yet",
|
||||
"699fe7ade5407687": "Direct Mode",
|
||||
"688102a402ba015a": "Waiting for login...",
|
||||
"6a7b96f399e58138": "e.g. sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "Test",
|
||||
"6ae23d6d7cb18592": "Service error",
|
||||
"6d7872164df9b36e": "Normal Input: {0} × ${1}/1M = {2}",
|
||||
"6e584e3d5ce64aa0": "Save Settings",
|
||||
"6ec87609a8769425": "Default {0} / Count as creation {1}",
|
||||
"72f6c3525c0192a7": "When enabled, cache creation is included in the denominator",
|
||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||
"737225e2904673fc": "Estimated output tokens: {0}",
|
||||
"7520bd50a5ee5471": "Stop testing {0}/{1}",
|
||||
"753d8bb0da9913ce": "Duplication failed",
|
||||
"774d6e1b7cb89751": "Default Definition",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"7a26bf794e9fb6bf": "Used only for display in the UI, so you can distinguish different models.",
|
||||
"7b6187c41e88b70c": "Testing...",
|
||||
"7bf8e2c07e084d09": "Model Editor",
|
||||
"7df7641e5e741346": "Cache Read / (Cache Read + Cache Creation + Non-cache Input)",
|
||||
"7e9e334aeb0bdc07": "Service operation failed",
|
||||
"7f68ebad19ba6bcd": "Check for Updates",
|
||||
"80296f4aa3f4543b": "Cache Read/Write",
|
||||
"81123c56d5d880d0": "API Key",
|
||||
"8139cb3dd11f5a67": "When enabled, the JSON object will override the final request headers. Duplicate headers are determined by this field, and values must be strings.",
|
||||
"8151e8704a7ca89e": "No matches",
|
||||
"83913e71fcf7ff60": "Refresh successful",
|
||||
"83be9cac28873059": "Cursor Control Plane Account",
|
||||
"83fcfb4c1f2c1641": "Fetch Models",
|
||||
"8672864e90417138": "Max",
|
||||
"86df7ec743047234": "Service running",
|
||||
"87ed126f7bd1121e": "Routing Mode",
|
||||
"899add6275682210": "Uses 200000 by default when left blank",
|
||||
"8c0d84831a3c3d5b": "Currently in Local Service Mode",
|
||||
"8a4ef3e48e4e8a5a": "Enabled",
|
||||
"8b8428f714611458": "Only select a reasoning effort when the model supports reasoning_effort. When set to Not set, the request omits this parameter. Higher values are usually more stable, but may also be slower.",
|
||||
"8c1935935600e336": "Model Test",
|
||||
"8cbcf741e727dbf7": "Model Settings",
|
||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"8f8baf5d18dd0492": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority; priority can be used for high-priority/Fast scenarios.",
|
||||
"8faa670b512b6b9b": "Open Model Settings",
|
||||
"907395551d0f3a93": "{0} OpenAI endpoint only supports /v1/responses, /v1/chat/completions, or a custom path starting with /",
|
||||
"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...",
|
||||
"970388573a3c88c9": "Cache Read: {0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "Max",
|
||||
"986678eccf56dc28": "Service status is being updated. Please try again later.",
|
||||
"991e374fce0f4492": "Cursor Assistant",
|
||||
"9970736b36ff2b68": "The base URL of the model service, usually an OpenAI- or Anthropic-compatible endpoint.",
|
||||
"9a6e7d6c17471711": "Currently displayed using the default hit rate definition",
|
||||
"9b17fa889b307f7f": "Valid turns: {0}",
|
||||
"9c38b6e9bf94abec": "Switched to Direct Cursor Mode",
|
||||
"9cd4ac17428b86e4": "Cache hit rate = cache read tokens / prompt tokens",
|
||||
"9c41b3a9e12ac994": "Thinking Effort",
|
||||
"9d2ca261281a158a": "Later",
|
||||
"9dc0825fba5422e4": "Loading...",
|
||||
"9d2fb46c0ba890b9": "Custom Path",
|
||||
"9e02529bcaef36c6": "Duplicate model channel detected. Please check the combination of url, modelID, apiKey, displayName, and endpoint",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a0d36236c523667c": "{0} Anthropic thinking effort only supports low, medium, high, xhigh, max",
|
||||
"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",
|
||||
"a54d745d9a9249e2": "When enabled, the JSON object will override the Anthropic request body. Duplicate fields are determined by this field.",
|
||||
"a567bdaa11367f26": "Medium",
|
||||
"a5f1bd344c92e195": "The API key required to call this model service.",
|
||||
"a693d69af48bfe48": "Save and Test",
|
||||
@@ -122,54 +151,80 @@
|
||||
"ac217e4d1ca410f1": "New version available",
|
||||
"ad79540418be700a": "Open the settings folder, or manage model settings separately",
|
||||
"ae5a738238463a92": "Hide API Key",
|
||||
"aeb1e3a8ff46cda1": "No models have been configured yet.",
|
||||
"aed55419ce62f08e": "Switching...",
|
||||
"b10041a13f5c55b1": "Model Output: {0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "High",
|
||||
"b42049dcf8a05ef7": "Switched to Local Service Mode",
|
||||
"b5409d4049286061": "Custom Path (Please enter the full request URL)",
|
||||
"b571037dc396a00c": "Total request tokens include both prompt and model output.",
|
||||
"b765005f69fa971f": "e.g. gpt-4.1",
|
||||
"b76a22622020f849": "Select interface protocol endpoint. When selecting 'Custom Path', please enter the complete request URL in the API address bar (including the /chat/completions or /responses suffix). The system will automatically detect the protocol type based on the trailing segment.",
|
||||
"b7ceef2fbfeb4a85": "e.g. GPT-5",
|
||||
"b870928f8f9a24c4": "API Endpoint",
|
||||
"b90a8ac9c488ce46": "Select language",
|
||||
"ba3c66f90fd11725": "System proxy identified",
|
||||
"ba40014ff496f64e": "Type",
|
||||
"bb074b86a98f6911": "Context Window",
|
||||
"bbacfde55a92869f": "A higher hit rate means more repeated context is being reused.",
|
||||
"bc87a4121a0873b3": "Refresh Stats",
|
||||
"bd4464ea88d3f24a": "Total turns: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} reasoning effort only supports Not set, low, medium, high, xhigh, and max",
|
||||
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
|
||||
"bef280f9eb392495": "Conversation Turns",
|
||||
"c228558cf257fc49": "Delete failed",
|
||||
"c3e9c3c60020b8b7": "Select Mode",
|
||||
"c3d46b387eeadb23": "This only logs the Cursor account out of cursor-byok; it does not log out of the Cursor client. Continue?",
|
||||
"c5af02060847d167": "Thinking effort for Anthropic adaptive thinking. Requests will consistently use the new thinking.type=adaptive.",
|
||||
"c6868592796ac2b2": "No {0} models have been configured yet.",
|
||||
"c69f5bce63b9f14c": "Settings Folder",
|
||||
"c6f743953145f40b": "e.g. 4096 (leave blank to use the default)",
|
||||
"c8a52b66651d294c": "Failed to log out",
|
||||
"c8c14507b2d37395": "Reasoning Effort",
|
||||
"c98e118e0a43f078": "Model",
|
||||
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
|
||||
"ca00a39fcea70dc6": "Starting...",
|
||||
"ca1d1059408b3837": "Invalid turns: {0}",
|
||||
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
|
||||
"cc5049729a2c10f1": "Test failed. Check the raw details.",
|
||||
"cd7ca5fb221e1c53": "{0} cannot be empty",
|
||||
"cfa6c803eb3fc713": "Waiting for browser login",
|
||||
"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",
|
||||
"d53d32f1a1211371": "Custom Headers JSON",
|
||||
"d6ce4f0f88178144": "Used only for Plugins, Skills, and MCP; does not change the account in the Cursor client",
|
||||
"d7889896c5b7732a": "Anthropic Extra Params JSON",
|
||||
"d7da2aabd35772ec": "e.g. 200000 (leave blank to use the default)",
|
||||
"d95e5cb6bdcee553": "Include Cache Creation",
|
||||
"da590a8fe3ce4de0": "Please select",
|
||||
"daede9881787abe7": "Notes",
|
||||
"dbee6e7139243362": "{0} base URL cannot be empty",
|
||||
"dc82c5e8fb2ab777": "Version: v{0}",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"e01c5dae36cf8c35": "When enabled, the JSON object will override the OpenAI request body. Duplicate fields are determined by this field. OpenAI service_tier supports auto, default, flex, scale, priority.",
|
||||
"e14c41ef2b7253c9": "Total request tokens: {0}",
|
||||
"e406825e0a72d2c2": "Local Settings",
|
||||
"e4343921c928a856": "Login failed",
|
||||
"e4c0daa3c4bea691": "Thanks to @aike0210 for contributing the Cursor control-plane account feature.",
|
||||
"e53580f8031f13c0": "Complete login in the browser, then return to Cursor and reopen the plugin marketplace",
|
||||
"e552c2accdbf5178": "Add Model",
|
||||
"e6faccfddce722e8": "Cache read tokens: {0}",
|
||||
"e8a0a6053998ebfa": "Logged in",
|
||||
"eaffd48cd2ea9f1a": "e.g. https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Estimated based on Claude Opus 4.7 pricing.",
|
||||
"ec3b17a75db49e24": "{0} t/s | First token {1}",
|
||||
"ec99e5c45d648fd6": "Update failed",
|
||||
"ee95057c6b0335d2": "Current outbound requests use system proxy: {0}",
|
||||
"f0b6a23368dd47cc": "Enter a model ID directly, or select one from the list returned by the server.",
|
||||
"f1aa7326f38b4c09": "Drag to reorder",
|
||||
"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",
|
||||
"f3fae6cccb9004b1": "Custom header name cannot be empty",
|
||||
"f474a4108aba4c4c": "Stop Service",
|
||||
"f4f0ead1116b5b62": "Enable",
|
||||
"f526ab6eff33039a": "Failed to open author page",
|
||||
"f56c6c82203b33f6": "Notice",
|
||||
"f61e03f047b786d5": "{0} max output tokens must be a positive integer",
|
||||
"f6e1c8b1a6970db5": "Current outbound requests do not use system proxy",
|
||||
"fac2a67ad87807c4": "OK",
|
||||
"fb7a4c81729ed0ca": "Stopping...",
|
||||
"fec45092945f8790": "User Guide"
|
||||
|
||||
@@ -1,119 +1,148 @@
|
||||
{
|
||||
"02216368edc68816": "更新内容はありません",
|
||||
"02bc2e95bf49e587": "まだ",
|
||||
"03b11112dc970014": "ベース URL",
|
||||
"045261cc748d4300": "Anthropic の思考フェーズで使用できる予算 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"047ec6b71d0cec08": "ホワイトリストのメイン経路のリクエストをローカルサービス経由にするか、元の Cursor 上流アドレスに戻すかを制御します",
|
||||
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
|
||||
"051836569928a9f9": "編集",
|
||||
"054d763265603305": "例: 65536(空欄でデフォルト値)",
|
||||
"05c8a9238c702efa": "Cursor 直結モード",
|
||||
"0647728439b5da2e": "ルーティングモードとモデルチャネルを設定できます。実行ログは次にあります",
|
||||
"092b520558eff5f2": "未テスト",
|
||||
"09ebc2643631ba25": "価値見積もり",
|
||||
"0b0e7478e41fe677": "{0} のツールチップは必須です",
|
||||
"0c3b4cf7aa259edb": "操作に失敗しました",
|
||||
"0dde813d719dbd01": "ホームページを開けませんでした",
|
||||
"0e40a09ab4e664ab": "モデルチャネルが重複しています。url、modelID、apiKey、displayName の組み合わせを確認してください",
|
||||
"0d6b7efd5ccefd8a": "モデルチャネルを設定できます。実行ログは次にあります",
|
||||
"1117a2f86030d03b": "キャッシュの読み書きは Prompt 側の統計に含まれます。",
|
||||
"11afd2a534395b18": "有効",
|
||||
"124be3f86f197802": "Token 使用量",
|
||||
"13b61c5f697b6700": "キャッシュヒット率",
|
||||
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
|
||||
"18b7312022cd1840": "サービスを開始",
|
||||
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
|
||||
"1b7d24b212e52c54": "{0} の推論強度は low、medium、high、xhigh のみサポートします",
|
||||
"1afed6a81a2512d2": "モデルを選択",
|
||||
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
|
||||
"1bc77f5ab979f4c1": "モデル設定を追加",
|
||||
"1c631615c1d85c9e": "Cursor にログイン",
|
||||
"1e238093b79b3165": "空欄で 65536",
|
||||
"21296ab18ad9af25": "追加パラメータ JSON",
|
||||
"24343a2096988d42": "開けませんでした",
|
||||
"253d4a3428c648fb": "キャッシュ書き込み Token: {0}",
|
||||
"258e4620e2108793": "空欄で 4096",
|
||||
"26a3855aed1d8d17": "サービスは起動していません",
|
||||
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
||||
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
||||
"2caeaec539e78898": "思考予算 Token",
|
||||
"2cd0f3be8738a86c": "キャンセル",
|
||||
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
||||
"2f9daa828907b93f": "削除",
|
||||
"30bb57a50caedc38": "例: 日常的なコード補完や Q&A 用",
|
||||
"3275d7e70bbdc3d5": "{0} の {1}",
|
||||
"32b3c9a50003f77a": "出力 Token は推定値です",
|
||||
"33d2d273e2bd5f88": "ユーザーガイドを開けませんでした",
|
||||
"3463c5585c246df9": "合計:{0}",
|
||||
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
|
||||
"35076178fe79a210": "設定が変更されました。再テストしてください",
|
||||
"36c149a9b3e8dca0": "モデルは設定されていません。",
|
||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||
"392d0dceb45998d3": "最高",
|
||||
"393df9bb13ea4900": "ヒット",
|
||||
"3ab8cc15939f3b5c": "ログアウト",
|
||||
"3af7e5489e61ea51": "更新中",
|
||||
"3bf8512aa520ed21": "ローカルサービスモード",
|
||||
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
||||
"3d13868593ae4eeb": "表示言語",
|
||||
"3d52574ce1500561": "未接続",
|
||||
"3ea83f9f55062582": "公開日時: {0}",
|
||||
"3edda85621fd03b2": "件のモデルアダプター",
|
||||
"3fd47edce45b3603": "閉じる",
|
||||
"42aa8e01e98c0d8c": "総所要時間",
|
||||
"438bb77c1ecdfab0": "キャッシュ書き込み:{0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "現在、再利用率の定義に従って表示されています",
|
||||
"468adaa418ee1475": "例: https://api.openai.com/v1",
|
||||
"472642d58d3d5a6d": "数式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
|
||||
"497c85690c4cc0fc": "データなし",
|
||||
"4b8d11bf235e9213": "現在のヒット率: {0}",
|
||||
"4b5e0ae1288a9695": "一致するモデルがありません",
|
||||
"4c0a929bb86ce912": "現在:{0}",
|
||||
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}({1})",
|
||||
"4d8c1c5b42830791": "不明",
|
||||
"51194c3ad014fb29": "再テストが必要",
|
||||
"4e30d7c9ed2b0eee": "設定しない",
|
||||
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
|
||||
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"54e6745ff43c9c74": "並べ替えに失敗しました",
|
||||
"56627c94a9decee6": "最大出力 Token",
|
||||
"58c6b0935a7216da": "コントリビューターのプロフィールを開けませんでした",
|
||||
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
|
||||
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
|
||||
"5aa8f5590c940829": "非キャッシュ入力:{0}",
|
||||
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"5c55a67935af8f45": "すべて",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}",
|
||||
"6106f0a12583a334": "再読み込みに失敗しました",
|
||||
"62873083fcaed27d": "セッション統計",
|
||||
"6309a3bb5ba4c714": "保存に失敗しました",
|
||||
"636e3deffc1e960a": "モデル {0}",
|
||||
"63d90d977348ab1f": "複製",
|
||||
"64857b4338678314": "カスタムヘッダー {0} の値は文字列である必要があります",
|
||||
"64d2730f2ae37997": "生のレスポンス",
|
||||
"65cc5fd2e6ce6e75": "バックエンドは起動済み、プロキシは未起動です",
|
||||
"66af574b8948fe83": "{0} の API キーは必須です",
|
||||
"6744b4c6a9aa0038": "無効化",
|
||||
"675109292da4eb36": "まだテストしていません",
|
||||
"699fe7ade5407687": "直結モード",
|
||||
"688102a402ba015a": "ログインを待っています...",
|
||||
"6a7b96f399e58138": "例: sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "テスト",
|
||||
"6ae23d6d7cb18592": "サービスエラー",
|
||||
"6d7872164df9b36e": "通常入力:{0} × ${1}/1M = {2}",
|
||||
"6e584e3d5ce64aa0": "設定を保存",
|
||||
"6ec87609a8769425": "デフォルト {0} / 作成カウント {1}",
|
||||
"72f6c3525c0192a7": "有効にすると、キャッシュ作成が分母に含まれます",
|
||||
"736c9dc2a04c65fd": "モデル設定が変更されました。更新してからもう一度お試しください。",
|
||||
"737225e2904673fc": "推定出力 Token: {0}",
|
||||
"7520bd50a5ee5471": "テスト停止 {0}/{1}",
|
||||
"753d8bb0da9913ce": "複製に失敗しました",
|
||||
"774d6e1b7cb89751": "デフォルト定義",
|
||||
"77c9e582e85583af": "テスト失敗",
|
||||
"7a26bf794e9fb6bf": "UI 上の表示専用で、異なるモデルを見分けやすくします。",
|
||||
"7a26bf794e9fb6bf": "UIでモデルを区別するための表示専用です。",
|
||||
"7b6187c41e88b70c": "テスト中...",
|
||||
"7bf8e2c07e084d09": "モデル編集",
|
||||
"7df7641e5e741346": "キャッシュ読み取り / (キャッシュ読み取り + キャッシュ作成 + 非キャッシュ入力)",
|
||||
"7e9e334aeb0bdc07": "サービス操作に失敗しました",
|
||||
"7f68ebad19ba6bcd": "アップデートを確認",
|
||||
"80296f4aa3f4543b": "キャッシュ読み書き",
|
||||
"81123c56d5d880d0": "API キー",
|
||||
"8139cb3dd11f5a67": "有効にすると、JSONオブジェクトが最終的なリクエストヘッダーを上書きします。同名のヘッダーはこの設定が優先され、値は文字列である必要があります。",
|
||||
"8151e8704a7ca89e": "一致する項目がありません",
|
||||
"83913e71fcf7ff60": "更新しました",
|
||||
"83be9cac28873059": "Cursor コントロールプレーンアカウント",
|
||||
"83fcfb4c1f2c1641": "モデルを取得",
|
||||
"8672864e90417138": "最大",
|
||||
"86df7ec743047234": "サービス稼働中",
|
||||
"87ed126f7bd1121e": "ルーティングモード",
|
||||
"899add6275682210": "空欄で 200000",
|
||||
"8c0d84831a3c3d5b": "現在はローカルサービスモードです",
|
||||
"8a4ef3e48e4e8a5a": "有効",
|
||||
"8b8428f714611458": "モデルが reasoning_effort に対応している場合のみ推論強度を選択してください。「設定しない」を選ぶと、リクエストにこのパラメータは含まれません。値が高いほど安定しやすい反面、遅くなることがあります。",
|
||||
"8c1935935600e336": "モデルテスト",
|
||||
"8cbcf741e727dbf7": "モデル設定",
|
||||
"8d1de152be6360ce": "有効率: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "コピーしました",
|
||||
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
||||
"8faa670b512b6b9b": "モデル設定を開く",
|
||||
"907395551d0f3a93": "{0} のOpenAIエンドポイントは、/v1/responses、/v1/chat/completions、または / で始まるカスタムパスのみをサポートしています",
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "すべてテスト",
|
||||
"91cba5c107a51892": "/ 異常",
|
||||
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
|
||||
"93e08803675e378b": "モデル ID",
|
||||
"93e08803675e378b": "モデルID",
|
||||
"942ff2d88baca0c6": "アップデートを確認中...",
|
||||
"970388573a3c88c9": "キャッシュ読み取り:{0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "最大",
|
||||
"986678eccf56dc28": "サービス状態を更新中です。しばらくしてからもう一度お試しください",
|
||||
"991e374fce0f4492": "Cursorアシスタント",
|
||||
"9970736b36ff2b68": "モデルサービスの API ルート URL。通常は OpenAI または Anthropic 互換のエンドポイントです。",
|
||||
"9a6e7d6c17471711": "現在、デフォルトのヒット率定義に従って表示されています",
|
||||
"9b17fa889b307f7f": "有効ターン: {0}",
|
||||
"9c38b6e9bf94abec": "Cursor 直結モードに切り替えました",
|
||||
"9cd4ac17428b86e4": "キャッシュヒット率 = キャッシュ読込 Token / Prompt Token",
|
||||
"9c41b3a9e12ac994": "思考強度",
|
||||
"9d2ca261281a158a": "後で",
|
||||
"9dc0825fba5422e4": "読み込み中...",
|
||||
"9d2fb46c0ba890b9": "カスタムパス",
|
||||
"9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください",
|
||||
"a026f37e613cf48b": "出力 Token",
|
||||
"a1a038dfa16c3ede": "すでに最新バージョンです(v{0})。",
|
||||
"a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています",
|
||||
"a1a038dfa16c3ede": "すでに最新バージョン(v{0})です。",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
"a325d25c69e7256d": "モデル設定が存在しないため複製できません",
|
||||
"a4dd8bb7e8b6eb31": "API キーを表示",
|
||||
"a55a88237df85d98": "現在は直結モードです",
|
||||
"a54d745d9a9249e2": "有効にすると、JSONオブジェクトがAnthropicのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "このモデルサービスを呼び出すために必要な API キーです。",
|
||||
"a693d69af48bfe48": "保存してテスト",
|
||||
@@ -122,54 +151,80 @@
|
||||
"ac217e4d1ca410f1": "新しいバージョンがあります",
|
||||
"ad79540418be700a": "設定フォルダーを開くか、モデル設定を個別に管理できます",
|
||||
"ae5a738238463a92": "API キーを隠す",
|
||||
"aeb1e3a8ff46cda1": "まだモデルが設定されていません。",
|
||||
"aed55419ce62f08e": "切替中...",
|
||||
"b10041a13f5c55b1": "モデル出力:{0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b42049dcf8a05ef7": "ローカルサービスモードに切り替えました",
|
||||
"b5409d4049286061": "カスタムパス(完全なリクエストURLを入力してください)",
|
||||
"b571037dc396a00c": "総リクエスト Token には Prompt とモデル出力の両方が含まれます。",
|
||||
"b765005f69fa971f": "例: gpt-4.1",
|
||||
"b76a22622020f849": "インターフェースプロトコルのエンドポイントを選択します。「カスタムパス」を選択する場合は、APIアドレスバーに完全なリクエストURL(/chat/completions または /responses のサフィックスを含む)を入力してください。システムは末尾 of セグメントに基づいてプロトコルタイプを自動的に判断します。",
|
||||
"b7ceef2fbfeb4a85": "例: GPT-5",
|
||||
"b870928f8f9a24c4": "APIエンドポイント",
|
||||
"b90a8ac9c488ce46": "言語を選択",
|
||||
"ba3c66f90fd11725": "システムプロキシを認識しました",
|
||||
"ba40014ff496f64e": "タイプ",
|
||||
"bb074b86a98f6911": "コンテキストウィンドウ",
|
||||
"bbacfde55a92869f": "ヒット率が高いほど、重複するコンテキストがより多く再利用されていることを示します。",
|
||||
"bc87a4121a0873b3": "統計を更新",
|
||||
"bd4464ea88d3f24a": "総ターン: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} の推論強度は「設定しない」、low、medium、high、xhigh、max のみサポートします",
|
||||
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
|
||||
"bef280f9eb392495": "会話ターン",
|
||||
"c228558cf257fc49": "削除に失敗しました",
|
||||
"c3e9c3c60020b8b7": "モードを選択",
|
||||
"c3d46b387eeadb23": "cursor-byok 内の Cursor アカウントからのみログアウトします。Cursor クライアントからはログアウトしません。続行しますか?",
|
||||
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
||||
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
|
||||
"c69f5bce63b9f14c": "設定フォルダー",
|
||||
"c6f743953145f40b": "例: 4096(空欄でデフォルト値)",
|
||||
"c8a52b66651d294c": "ログアウトに失敗しました",
|
||||
"c8c14507b2d37395": "推論強度",
|
||||
"c98e118e0a43f078": "モデル",
|
||||
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
|
||||
"ca00a39fcea70dc6": "起動中...",
|
||||
"ca1d1059408b3837": "異常ターン: {0}",
|
||||
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
|
||||
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
|
||||
"cd7ca5fb221e1c53": "{0}は空にできません",
|
||||
"cfa6c803eb3fc713": "ブラウザでのログインを待っています",
|
||||
"d0325067fed88e5a": "キャッシュヒット率 {0}",
|
||||
"d08fd4224abcd69d": "切替に失敗しました",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
|
||||
"d20ab96566d33f25": "{0} の表示名は必須です",
|
||||
"d2243e1d44b2a94e": "モデル設定を編集",
|
||||
"d3209b935ae86797": "モデル設定が存在しないため削除できません",
|
||||
"d373809ab86ba93b": "コピー",
|
||||
"d3b1da3088ddd334": "モデルテストに失敗しました",
|
||||
"d53d32f1a1211371": "カスタムヘッダー JSON",
|
||||
"d6ce4f0f88178144": "プラグイン、Skills、MCP 専用です。Cursor クライアントの現在のアカウントは変更しません",
|
||||
"d7889896c5b7732a": "Anthropic 追加パラメータ JSON",
|
||||
"d7da2aabd35772ec": "例: 200000(空欄でデフォルト値)",
|
||||
"d95e5cb6bdcee553": "キャッシュ作成を含める",
|
||||
"da590a8fe3ce4de0": "選択してください",
|
||||
"daede9881787abe7": "メモ",
|
||||
"dbee6e7139243362": "{0} のベース URL は必須です",
|
||||
"dc82c5e8fb2ab777": "バージョン: v{0}",
|
||||
"de8184da1ef88d03": "設定済み",
|
||||
"e01c5dae36cf8c35": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしています。",
|
||||
"e14c41ef2b7253c9": "総リクエスト Token: {0}",
|
||||
"e406825e0a72d2c2": "ローカル設定",
|
||||
"e4343921c928a856": "ログインに失敗しました",
|
||||
"e4c0daa3c4bea691": "Cursor コントロールプレーンアカウント機能への @aike0210 の貢献に感謝します。",
|
||||
"e53580f8031f13c0": "ブラウザでログインを完了し、Cursor に戻ってプラグインマーケットを開き直してください",
|
||||
"e552c2accdbf5178": "モデルを追加",
|
||||
"e6faccfddce722e8": "キャッシュ読込 Token: {0}",
|
||||
"e8a0a6053998ebfa": "ログイン済み",
|
||||
"eaffd48cd2ea9f1a": "例: https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Claude Opus 4.7の価格に基づいて見積もられます。",
|
||||
"ec3b17a75db49e24": "{0} t/s | 初回 Token {1}",
|
||||
"ec99e5c45d648fd6": "アップデートに失敗しました",
|
||||
"ee95057c6b0335d2": "現在のアウトバウンドリクエストはシステムプロキシを使用しています:{0}",
|
||||
"f0b6a23368dd47cc": "モデルIDを直接入力するか、サーバーから返された一覧から選択します。",
|
||||
"f1aa7326f38b4c09": "ドラッグして並べ替え",
|
||||
"f1e0fc261d42fe29": "モデル一覧にホバーしたときに表示されるメモです。",
|
||||
"f363622480699c52": "推論強度は reasoning_effort をサポートする一部のモデルでのみ有効です。すべてのモデルが対応しているわけではありません。値が高いほど安定しやすい反面、遅くなることがあります。",
|
||||
"f3a76d896853c1df": "ミス",
|
||||
"f3fae6cccb9004b1": "カスタムヘッダー名は空にできません",
|
||||
"f474a4108aba4c4c": "サービスを停止",
|
||||
"f4f0ead1116b5b62": "有効化",
|
||||
"f526ab6eff33039a": "作者ページを開けませんでした",
|
||||
"f56c6c82203b33f6": "お知らせ",
|
||||
"f61e03f047b786d5": "{0} の最大出力 Token は正の整数である必要があります",
|
||||
"f6e1c8b1a6970db5": "現在のアウトバウンドリクエストはシステムプロキシを使用していません",
|
||||
"fac2a67ad87807c4": "OK",
|
||||
"fb7a4c81729ed0ca": "停止中...",
|
||||
"fec45092945f8790": "ユーザーガイド"
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"02216368edc68816": "Нет примечаний к выпуску",
|
||||
"03b11112dc970014": "Базовый URL",
|
||||
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
|
||||
"051836569928a9f9": "Изменить",
|
||||
"054d763265603305": "например, 65536 (оставьте пустым для значения по умолчанию)",
|
||||
"092b520558eff5f2": "Не проверено",
|
||||
"09ebc2643631ba25": "Оценка стоимости",
|
||||
"0b0e7478e41fe677": "Текст подсказки {0} не может быть пустым",
|
||||
"0c3b4cf7aa259edb": "Не удалось выполнить операцию",
|
||||
"0d6b7efd5ccefd8a": "Здесь можно настроить каналы моделей. Журналы выполнения находятся в",
|
||||
"1117a2f86030d03b": "Чтение и запись кеша включены в статистику Prompt.",
|
||||
"11afd2a534395b18": "Успешно",
|
||||
"124be3f86f197802": "Использование токенов",
|
||||
"13b61c5f697b6700": "Доля попаданий в кеш",
|
||||
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
|
||||
"185aebe19c77425d": "{0} должен быть объектом JSON",
|
||||
"18b7312022cd1840": "Запустить сервис",
|
||||
"1afed6a81a2512d2": "Выберите модель",
|
||||
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
|
||||
"1bc77f5ab979f4c1": "Добавить настройки модели",
|
||||
"1c631615c1d85c9e": "Войти в Cursor",
|
||||
"1e238093b79b3165": "Если оставить пустым, используется 65536",
|
||||
"21296ab18ad9af25": "Дополнительные параметры JSON",
|
||||
"24343a2096988d42": "Не удалось открыть",
|
||||
"253d4a3428c648fb": "Токены записи в кеш: {0}",
|
||||
"26a3855aed1d8d17": "Сервис не запущен",
|
||||
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
|
||||
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
|
||||
"2cd0f3be8738a86c": "Отмена",
|
||||
"2d706f7981b45a7b": "Локальные настройки сохранены",
|
||||
"2f9daa828907b93f": "Удалить",
|
||||
"30bb57a50caedc38": "например, для ежедневного дополнения кода и ответов на вопросы",
|
||||
"3275d7e70bbdc3d5": "{1} для {0}",
|
||||
"32b3c9a50003f77a": "Количество выходных токенов рассчитано приблизительно",
|
||||
"33d2d273e2bd5f88": "Не удалось открыть руководство пользователя",
|
||||
"3463c5585c246df9": "Итого: {0}",
|
||||
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
|
||||
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
|
||||
"37d23612f78a2e63": "Перезапустить и обновить",
|
||||
"392d0dceb45998d3": "Очень высокая",
|
||||
"393df9bb13ea4900": "Попадание",
|
||||
"3ab8cc15939f3b5c": "Выйти",
|
||||
"3af7e5489e61ea51": "Обновление",
|
||||
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
|
||||
"3d13868593ae4eeb": "Язык интерфейса",
|
||||
"3d52574ce1500561": "Не подключено",
|
||||
"3ea83f9f55062582": "Дата выпуска: {0}",
|
||||
"3edda85621fd03b2": "адаптеров моделей",
|
||||
"3fd47edce45b3603": "Закрыть",
|
||||
"42aa8e01e98c0d8c": "Общая длительность",
|
||||
"438bb77c1ecdfab0": "Запись в кеш: {0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "Сейчас используется расчет по коэффициенту повторного использования",
|
||||
"468adaa418ee1475": "например, https://api.openai.com/v1",
|
||||
"472642d58d3d5a6d": "Формула: {0}",
|
||||
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
|
||||
"497c85690c4cc0fc": "Нет данных",
|
||||
"4b5e0ae1288a9695": "Подходящих моделей нет",
|
||||
"4c0a929bb86ce912": "Сейчас: {0}",
|
||||
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Неизвестно",
|
||||
"4e30d7c9ed2b0eee": "Не задано",
|
||||
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
|
||||
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
|
||||
"54e6745ff43c9c74": "Не удалось изменить порядок",
|
||||
"56627c94a9decee6": "Макс. выходных токенов",
|
||||
"58c6b0935a7216da": "Не удалось открыть профиль участника",
|
||||
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
|
||||
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
|
||||
"5aa8f5590c940829": "Ввод без кеша: {0}",
|
||||
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
|
||||
"5c55a67935af8f45": "Все",
|
||||
"5d1687a4a41883fd": "Остановка...",
|
||||
"5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}",
|
||||
"6106f0a12583a334": "Не удалось обновить",
|
||||
"62873083fcaed27d": "Статистика сеанса",
|
||||
"6309a3bb5ba4c714": "Не удалось сохранить",
|
||||
"636e3deffc1e960a": "Модель {0}",
|
||||
"63d90d977348ab1f": "Дублировать",
|
||||
"64857b4338678314": "Значение пользовательского заголовка {0} должно быть строкой",
|
||||
"64d2730f2ae37997": "Исходный ответ",
|
||||
"65cc5fd2e6ce6e75": "Бэкенд запущен, прокси не запущен",
|
||||
"66af574b8948fe83": "Ключ API {0} не может быть пустым",
|
||||
"6744b4c6a9aa0038": "Выключено",
|
||||
"675109292da4eb36": "Еще не проверено",
|
||||
"688102a402ba015a": "Ожидание входа...",
|
||||
"6a7b96f399e58138": "например, sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "Проверить",
|
||||
"6ae23d6d7cb18592": "Ошибка сервиса",
|
||||
"6d7872164df9b36e": "Обычный ввод: {0} × ${1}/1M = {2}",
|
||||
"6e584e3d5ce64aa0": "Сохранить настройки",
|
||||
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
|
||||
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
|
||||
"736c9dc2a04c65fd": "Конфигурация моделей изменилась. Обновите данные и повторите попытку.",
|
||||
"737225e2904673fc": "Расчетные выходные токены: {0}",
|
||||
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
|
||||
"753d8bb0da9913ce": "Не удалось дублировать",
|
||||
"774d6e1b7cb89751": "Стандартный расчет",
|
||||
"77c9e582e85583af": "Проверка не пройдена",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
|
||||
"7b6187c41e88b70c": "Проверка...",
|
||||
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
||||
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
|
||||
"7f68ebad19ba6bcd": "Проверить обновления",
|
||||
"80296f4aa3f4543b": "Чтение/запись кеша",
|
||||
"81123c56d5d880d0": "Ключ API",
|
||||
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
|
||||
"8151e8704a7ca89e": "Совпадений нет",
|
||||
"83913e71fcf7ff60": "Обновление выполнено",
|
||||
"83be9cac28873059": "Аккаунт управляющего уровня Cursor",
|
||||
"83fcfb4c1f2c1641": "Получить модели",
|
||||
"8672864e90417138": "Максимальная",
|
||||
"86df7ec743047234": "Сервис запущен",
|
||||
"899add6275682210": "Если оставить пустым, используется 200000",
|
||||
"8a4ef3e48e4e8a5a": "Включено",
|
||||
"8b8428f714611458": "Выбирайте интенсивность рассуждений только для моделей с поддержкой reasoning_effort. Если выбрать «Не задано», этот параметр не будет добавлен в запрос. Более высокие значения обычно дают более стабильный результат, но могут замедлить ответ.",
|
||||
"8c1935935600e336": "Проверка модели",
|
||||
"8cbcf741e727dbf7": "Настройки модели",
|
||||
"8d1de152be6360ce": "Доля успешных: {0}",
|
||||
"8f6f8d979c981ced": "Скопировано",
|
||||
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
||||
"8faa670b512b6b9b": "Открыть настройки модели",
|
||||
"907395551d0f3a93": "Конечная точка OpenAI для {0} поддерживает только /v1/responses, /v1/chat/completions или пользовательский путь, начинающийся с /",
|
||||
"917b1c1f18d0276b": "Сохранение...",
|
||||
"9196835e388d2550": "Проверить все",
|
||||
"91cba5c107a51892": "/ Ошибочные",
|
||||
"93e08803675e378b": "Идентификатор модели",
|
||||
"942ff2d88baca0c6": "Проверка обновлений...",
|
||||
"970388573a3c88c9": "Чтение кеша: {0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "Макс.",
|
||||
"986678eccf56dc28": "Состояние сервиса обновляется. Повторите попытку позже.",
|
||||
"9970736b36ff2b68": "Корневой адрес API сервиса модели, обычно совместимый с OpenAI или Anthropic.",
|
||||
"9a6e7d6c17471711": "Сейчас используется стандартный расчет доли попаданий",
|
||||
"9b17fa889b307f7f": "Успешных ходов: {0}",
|
||||
"9c41b3a9e12ac994": "Интенсивность рассуждений",
|
||||
"9d2ca261281a158a": "Позже",
|
||||
"9d2fb46c0ba890b9": "Пользовательский путь",
|
||||
"9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint",
|
||||
"a026f37e613cf48b": "Выходные токены",
|
||||
"a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max",
|
||||
"a1a038dfa16c3ede": "У вас уже установлена последняя версия (v{0}).",
|
||||
"a3030bf8f16dc63c": "Сохранить",
|
||||
"a325d25c69e7256d": "Настройки модели не найдены; дублирование невозможно",
|
||||
"a4dd8bb7e8b6eb31": "Показать ключ API",
|
||||
"a54d745d9a9249e2": "Если включено, объект JSON переопределит тело запроса Anthropic. При совпадении полей используются значения отсюда.",
|
||||
"a567bdaa11367f26": "Средняя",
|
||||
"a5f1bd344c92e195": "Ключ API, необходимый для обращения к сервису модели.",
|
||||
"a693d69af48bfe48": "Сохранить и проверить",
|
||||
"a98585871c5313ff": "Отображаемое имя",
|
||||
"aa9e366f68d3d097": "Низкая",
|
||||
"ac217e4d1ca410f1": "Доступна новая версия",
|
||||
"ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей",
|
||||
"ae5a738238463a92": "Скрыть ключ API",
|
||||
"aeb1e3a8ff46cda1": "Модели пока не настроены.",
|
||||
"aed55419ce62f08e": "Переключение...",
|
||||
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "Высокая",
|
||||
"b5409d4049286061": "Пользовательский путь (введите полный URL запроса)",
|
||||
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
||||
"b765005f69fa971f": "например, gpt-4.1",
|
||||
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
|
||||
"b7ceef2fbfeb4a85": "например, GPT-5",
|
||||
"b870928f8f9a24c4": "Конечная точка API",
|
||||
"b90a8ac9c488ce46": "Выберите язык",
|
||||
"ba3c66f90fd11725": "Обнаружен системный прокси",
|
||||
"ba40014ff496f64e": "Тип",
|
||||
"bb074b86a98f6911": "Контекстное окно",
|
||||
"bc87a4121a0873b3": "Обновить статистику",
|
||||
"bd4464ea88d3f24a": "Всего ходов: {0}",
|
||||
"bd4d7a3c6e5a1ac8": "Интенсивность рассуждений {0} поддерживает только значения «Не задано», low, medium, high, xhigh и max",
|
||||
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
|
||||
"bef280f9eb392495": "Ходы диалога",
|
||||
"c228558cf257fc49": "Не удалось удалить",
|
||||
"c3d46b387eeadb23": "Будет выполнен выход только из аккаунта Cursor в cursor-byok. В клиенте Cursor вы останетесь в системе. Продолжить?",
|
||||
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
||||
"c6868592796ac2b2": "Модели {0} пока не настроены.",
|
||||
"c69f5bce63b9f14c": "Папка настроек",
|
||||
"c8a52b66651d294c": "Не удалось выйти",
|
||||
"c8c14507b2d37395": "Интенсивность рассуждений",
|
||||
"c98e118e0a43f078": "Модель",
|
||||
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
|
||||
"ca00a39fcea70dc6": "Запуск...",
|
||||
"ca1d1059408b3837": "Ошибочных ходов: {0}",
|
||||
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
|
||||
"cd7ca5fb221e1c53": "{0} не может быть пустым",
|
||||
"cfa6c803eb3fc713": "Ожидание входа в браузере",
|
||||
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
|
||||
"d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым",
|
||||
"d2243e1d44b2a94e": "Изменить настройки модели",
|
||||
"d3209b935ae86797": "Настройки модели не найдены; удаление невозможно",
|
||||
"d373809ab86ba93b": "Копировать",
|
||||
"d3b1da3088ddd334": "Проверка модели не пройдена",
|
||||
"d53d32f1a1211371": "Пользовательские заголовки JSON",
|
||||
"d6ce4f0f88178144": "Используется только для Plugins, Skills и MCP; текущий аккаунт клиента Cursor не изменяется",
|
||||
"d7889896c5b7732a": "Дополнительные параметры Anthropic JSON",
|
||||
"d7da2aabd35772ec": "например, 200000 (оставьте пустым для значения по умолчанию)",
|
||||
"d95e5cb6bdcee553": "Учитывать создание кеша",
|
||||
"da590a8fe3ce4de0": "Выберите значение",
|
||||
"daede9881787abe7": "Примечания",
|
||||
"dbee6e7139243362": "Базовый URL {0} не может быть пустым",
|
||||
"dc82c5e8fb2ab777": "Версия: v{0}",
|
||||
"de8184da1ef88d03": "Настроено",
|
||||
"e01c5dae36cf8c35": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority.",
|
||||
"e14c41ef2b7253c9": "Всего токенов запроса: {0}",
|
||||
"e406825e0a72d2c2": "Локальные настройки",
|
||||
"e4343921c928a856": "Не удалось войти",
|
||||
"e4c0daa3c4bea691": "Спасибо @aike0210 за вклад в функцию аккаунта панели управления Cursor.",
|
||||
"e53580f8031f13c0": "Завершите вход в браузере, затем вернитесь в Cursor и снова откройте магазин плагинов",
|
||||
"e552c2accdbf5178": "Добавить модель",
|
||||
"e6faccfddce722e8": "Токены чтения из кеша: {0}",
|
||||
"e8a0a6053998ebfa": "Выполнен вход",
|
||||
"eaffd48cd2ea9f1a": "например, https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "Расчет основан на тарифах Claude Opus 4.7.",
|
||||
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
|
||||
"ec99e5c45d648fd6": "Не удалось обновить",
|
||||
"ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}",
|
||||
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
|
||||
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
|
||||
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
|
||||
"f3a76d896853c1df": "Промах",
|
||||
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
||||
"f474a4108aba4c4c": "Остановить сервис",
|
||||
"f4f0ead1116b5b62": "Включить",
|
||||
"f526ab6eff33039a": "Не удалось открыть страницу автора",
|
||||
"f56c6c82203b33f6": "Уведомление",
|
||||
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
|
||||
"f6e1c8b1a6970db5": "Исходящие запросы не используют системный прокси",
|
||||
"fac2a67ad87807c4": "ОК",
|
||||
"fb7a4c81729ed0ca": "Остановка...",
|
||||
"fec45092945f8790": "Руководство пользователя"
|
||||
}
|
||||
@@ -1,119 +1,148 @@
|
||||
{
|
||||
"02216368edc68816": "无更新说明",
|
||||
"02bc2e95bf49e587": "当前还没有配置任何",
|
||||
"03b11112dc970014": "接口地址",
|
||||
"045261cc748d4300": "Anthropic 思考阶段允许消耗的预算 Token 数。留空时使用默认值。",
|
||||
"047ec6b71d0cec08": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址",
|
||||
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
|
||||
"051836569928a9f9": "编辑",
|
||||
"054d763265603305": "例如:65536(留空用默认值)",
|
||||
"05c8a9238c702efa": "直连 Cursor 模式",
|
||||
"0647728439b5da2e": "可配置运行模式和模型渠道;运行日志位于",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0b0e7478e41fe677": "{0} 的悬停提示不能为空",
|
||||
"0c3b4cf7aa259edb": "操作失败",
|
||||
"0dde813d719dbd01": "打开主页失败",
|
||||
"0e40a09ab4e664ab": "模型渠道重复,请检查 url、modelID、apiKey、displayName 组合",
|
||||
"0d6b7efd5ccefd8a": "可配置模型渠道;运行日志位于",
|
||||
"1117a2f86030d03b": "缓存读写已计入 Prompt 侧统计。",
|
||||
"11afd2a534395b18": "有效",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
"13b61c5f697b6700": "缓存命中率",
|
||||
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
"185aebe19c77425d": "{0}必须是 JSON 对象",
|
||||
"18b7312022cd1840": "启动服务",
|
||||
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
|
||||
"1b7d24b212e52c54": "{0} 的推理强度仅支持 low、medium、high、xhigh",
|
||||
"1afed6a81a2512d2": "选择模型",
|
||||
"1baddde657dd2720": "当前出站请求使用系统代理",
|
||||
"1bc77f5ab979f4c1": "新增模型配置",
|
||||
"1c631615c1d85c9e": "登录 Cursor",
|
||||
"1e238093b79b3165": "留空时默认 65536",
|
||||
"21296ab18ad9af25": "额外参数 JSON",
|
||||
"24343a2096988d42": "打开失败",
|
||||
"253d4a3428c648fb": "缓存写入:{0}",
|
||||
"258e4620e2108793": "留空时默认 4096",
|
||||
"26a3855aed1d8d17": "服务未启动",
|
||||
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
||||
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d706f7981b45a7b": "本地配置已保存",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"30bb57a50caedc38": "例如:用于日常代码补全与问答",
|
||||
"3275d7e70bbdc3d5": "{0} 的 {1}",
|
||||
"32b3c9a50003f77a": "输出 Token 为估算值",
|
||||
"33d2d273e2bd5f88": "打开使用教程失败",
|
||||
"3463c5585c246df9": "合计:{0}",
|
||||
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
|
||||
"35076178fe79a210": "配置已变更,请重新测试",
|
||||
"36c149a9b3e8dca0": "模型。",
|
||||
"37d23612f78a2e63": "立即重启更新",
|
||||
"392d0dceb45998d3": "极高",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"3ab8cc15939f3b5c": "退出登录",
|
||||
"3af7e5489e61ea51": "刷新中",
|
||||
"3bf8512aa520ed21": "本地服务模式",
|
||||
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3d52574ce1500561": "未连接",
|
||||
"3ea83f9f55062582": "发布时间:{0}",
|
||||
"3edda85621fd03b2": "个模型适配器",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
"42aa8e01e98c0d8c": "总耗时",
|
||||
"438bb77c1ecdfab0": "缓存写入:{0} × ${1}/1M = {2}",
|
||||
"46056425a48ef05b": "当前按复用率口径显示",
|
||||
"468adaa418ee1475": "例如:https://api.openai.com/v1",
|
||||
"472642d58d3d5a6d": "公式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"4b8d11bf235e9213": "当前命中率:{0}",
|
||||
"4b5e0ae1288a9695": "没有匹配的模型",
|
||||
"4c0a929bb86ce912": "当前:{0}",
|
||||
"4d2b6e53be6002e5": "缓存统计策略:{0}({1})",
|
||||
"4d8c1c5b42830791": "未知",
|
||||
"51194c3ad014fb29": "需重测",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
|
||||
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"58c6b0935a7216da": "打开贡献者主页失败",
|
||||
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
|
||||
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
|
||||
"5aa8f5590c940829": "非缓存输入:{0}",
|
||||
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}",
|
||||
"6106f0a12583a334": "刷新失败",
|
||||
"62873083fcaed27d": "会话统计",
|
||||
"6309a3bb5ba4c714": "保存失败",
|
||||
"636e3deffc1e960a": "模型 {0}",
|
||||
"63d90d977348ab1f": "复制",
|
||||
"64857b4338678314": "自定义请求头 {0} 的值必须是字符串",
|
||||
"64d2730f2ae37997": "原始返回",
|
||||
"65cc5fd2e6ce6e75": "后端已启动,代理未启动",
|
||||
"66af574b8948fe83": "{0} 的访问密钥不能为空",
|
||||
"6744b4c6a9aa0038": "已关闭",
|
||||
"675109292da4eb36": "尚未测试",
|
||||
"699fe7ade5407687": "直连模式",
|
||||
"688102a402ba015a": "等待登录...",
|
||||
"6a7b96f399e58138": "例如:sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "测试",
|
||||
"6ae23d6d7cb18592": "服务错误",
|
||||
"6d7872164df9b36e": "普通输入:{0} × ${1}/1M = {2}",
|
||||
"6e584e3d5ce64aa0": "保存配置",
|
||||
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
|
||||
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"737225e2904673fc": "输出推算:{0}",
|
||||
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
||||
"753d8bb0da9913ce": "复制失败",
|
||||
"774d6e1b7cb89751": "默认口径",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||
"7b6187c41e88b70c": "测试中...",
|
||||
"7bf8e2c07e084d09": "模型编辑",
|
||||
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
|
||||
"7e9e334aeb0bdc07": "服务操作失败",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"80296f4aa3f4543b": "缓存读写",
|
||||
"81123c56d5d880d0": "访问密钥",
|
||||
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
||||
"8151e8704a7ca89e": "没有匹配项",
|
||||
"83913e71fcf7ff60": "刷新成功",
|
||||
"83be9cac28873059": "Cursor 控制面账号",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"8672864e90417138": "最高",
|
||||
"86df7ec743047234": "服务运行中",
|
||||
"87ed126f7bd1121e": "运行模式",
|
||||
"899add6275682210": "留空时默认 200000",
|
||||
"8c0d84831a3c3d5b": "当前为本地服务模式",
|
||||
"8a4ef3e48e4e8a5a": "已开启",
|
||||
"8b8428f714611458": "仅当模型支持 reasoning_effort 时才选择推理强度;选择“不设置”后,请求不会携带该参数。越高通常越稳,但也可能更慢。",
|
||||
"8c1935935600e336": "模型测试",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8d1de152be6360ce": "有效占比:{0}",
|
||||
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
"8faa670b512b6b9b": "打开模型配置",
|
||||
"907395551d0f3a93": "{0} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径",
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "测试全部",
|
||||
"91cba5c107a51892": "/ 异常",
|
||||
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
"93e08803675e378b": "模型标识",
|
||||
"942ff2d88baca0c6": "检查更新中...",
|
||||
"970388573a3c88c9": "缓存读取:{0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "最大",
|
||||
"986678eccf56dc28": "服务状态更新中,请稍后再试",
|
||||
"991e374fce0f4492": "Cursor助手",
|
||||
"9970736b36ff2b68": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
"9a6e7d6c17471711": "当前按默认命中率口径显示",
|
||||
"9b17fa889b307f7f": "有效轮次:{0}",
|
||||
"9c38b6e9bf94abec": "已切换到直连 Cursor 模式",
|
||||
"9cd4ac17428b86e4": "缓存命中率 = 缓存读取 Token / Prompt Token",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9d2ca261281a158a": "稍后",
|
||||
"9dc0825fba5422e4": "加载中...",
|
||||
"9d2fb46c0ba890b9": "自定义路径",
|
||||
"9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max",
|
||||
"a1a038dfa16c3ede": "当前已是最新版本(v{0})。",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
"a325d25c69e7256d": "模型配置不存在,无法复制",
|
||||
"a4dd8bb7e8b6eb31": "显示访问密钥",
|
||||
"a55a88237df85d98": "当前为直连模式",
|
||||
"a54d745d9a9249e2": "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "调用该模型服务需要使用的访问密钥。",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
@@ -122,54 +151,80 @@
|
||||
"ac217e4d1ca410f1": "发现新版本",
|
||||
"ad79540418be700a": "打开设置目录,或单独管理模型配置",
|
||||
"ae5a738238463a92": "隐藏访问密钥",
|
||||
"aeb1e3a8ff46cda1": "当前还没有配置任何模型。",
|
||||
"aed55419ce62f08e": "切换中...",
|
||||
"b10041a13f5c55b1": "模型输出:{0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "高",
|
||||
"b42049dcf8a05ef7": "已切换到本地服务模式",
|
||||
"b5409d4049286061": "自定义路径(请输入完整请求地址)",
|
||||
"b571037dc396a00c": "总请求 Token 包含 Prompt 和模型输出。",
|
||||
"b765005f69fa971f": "例如:gpt-4.1",
|
||||
"b76a22622020f849": "选择接口协议端点。选“自定义路径”时,请在接口地址栏填写完整请求地址(含 /chat/completions 或 /responses 路径后缀),系统会根据末段自动判断协议形态。",
|
||||
"b7ceef2fbfeb4a85": "例如:GPT-5",
|
||||
"b870928f8f9a24c4": "接口端点",
|
||||
"b90a8ac9c488ce46": "选择语言",
|
||||
"ba3c66f90fd11725": "已识别系统代理",
|
||||
"ba40014ff496f64e": "类型",
|
||||
"bb074b86a98f6911": "上下文窗口",
|
||||
"bbacfde55a92869f": "命中率越高,说明重复上下文复用得越多。",
|
||||
"bc87a4121a0873b3": "刷新统计",
|
||||
"bd4464ea88d3f24a": "总轮次:{0}",
|
||||
"bd4d7a3c6e5a1ac8": "{0} 的推理强度仅支持不设置、low、medium、high、xhigh、max",
|
||||
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
|
||||
"bef280f9eb392495": "对话轮次",
|
||||
"c228558cf257fc49": "删除失败",
|
||||
"c3e9c3c60020b8b7": "选择模式",
|
||||
"c3d46b387eeadb23": "只会退出 cursor-byok 中的 Cursor 账号,不会退出 Cursor 客户端。是否继续?",
|
||||
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
||||
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
|
||||
"c69f5bce63b9f14c": "设置文件夹",
|
||||
"c6f743953145f40b": "例如:4096(留空用默认值)",
|
||||
"c8a52b66651d294c": "退出登录失败",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c98e118e0a43f078": "模型",
|
||||
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
|
||||
"ca00a39fcea70dc6": "启动中...",
|
||||
"ca1d1059408b3837": "异常轮次:{0}",
|
||||
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
|
||||
"cc5049729a2c10f1": "测试失败,请查看原始信息",
|
||||
"cd7ca5fb221e1c53": "{0}不能为空",
|
||||
"cfa6c803eb3fc713": "等待浏览器登录",
|
||||
"d0325067fed88e5a": "缓存命中率 {0}",
|
||||
"d08fd4224abcd69d": "切换失败",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
|
||||
"d20ab96566d33f25": "{0} 的显示名称不能为空",
|
||||
"d2243e1d44b2a94e": "编辑模型配置",
|
||||
"d3209b935ae86797": "模型配置不存在,无法删除",
|
||||
"d373809ab86ba93b": "拷贝",
|
||||
"d3b1da3088ddd334": "模型测试失败",
|
||||
"d53d32f1a1211371": "自定义请求头 JSON",
|
||||
"d6ce4f0f88178144": "独立用于插件、Skills 和 MCP;不会改变 Cursor 客户端当前账号",
|
||||
"d7889896c5b7732a": "Anthropic 额外参数 JSON",
|
||||
"d7da2aabd35772ec": "例如:200000(留空用默认值)",
|
||||
"d95e5cb6bdcee553": "计入缓存创建",
|
||||
"da590a8fe3ce4de0": "请选择",
|
||||
"daede9881787abe7": "备注",
|
||||
"dbee6e7139243362": "{0} 的接口地址不能为空",
|
||||
"dc82c5e8fb2ab777": "版本:v{0}",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"e01c5dae36cf8c35": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority。",
|
||||
"e14c41ef2b7253c9": "总请求:{0}",
|
||||
"e406825e0a72d2c2": "本地配置",
|
||||
"e4343921c928a856": "登录失败",
|
||||
"e4c0daa3c4bea691": "感谢 @aike0210 对 Cursor 控制面账号功能的贡献。",
|
||||
"e53580f8031f13c0": "请在浏览器完成登录,完成后返回 Cursor 重新打开插件市场",
|
||||
"e552c2accdbf5178": "新增模型",
|
||||
"e6faccfddce722e8": "缓存读取:{0}",
|
||||
"e8a0a6053998ebfa": "已经登录",
|
||||
"eaffd48cd2ea9f1a": "例如:https://api.anthropic.com",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"ec3b17a75db49e24": "{0} t/s | 首字 {1}",
|
||||
"ec99e5c45d648fd6": "更新失败",
|
||||
"ee95057c6b0335d2": "当前出站请求使用系统代理:{0}",
|
||||
"f0b6a23368dd47cc": "可以直接输入模型标识,或从服务端返回的列表中选择。",
|
||||
"f1aa7326f38b4c09": "拖拽排序",
|
||||
"f1e0fc261d42fe29": "模型列表 hover 时显示的备注说明。",
|
||||
"f363622480699c52": "推理强度仅对部分支持 reasoning_effort 的模型生效,并不是所有模型都支持。越高通常越稳,但也可能更慢。",
|
||||
"f3a76d896853c1df": "未命中",
|
||||
"f3fae6cccb9004b1": "自定义请求头名称不能为空",
|
||||
"f474a4108aba4c4c": "关闭服务",
|
||||
"f4f0ead1116b5b62": "启用",
|
||||
"f526ab6eff33039a": "打开作者地址失败",
|
||||
"f56c6c82203b33f6": "提示",
|
||||
"f61e03f047b786d5": "{0} 的最大输出 Token 必须为正整数",
|
||||
"f6e1c8b1a6970db5": "当前出站请求未使用系统代理",
|
||||
"fac2a67ad87807c4": "确定",
|
||||
"fb7a4c81729ed0ca": "关闭中...",
|
||||
"fec45092945f8790": "使用教程"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_OPTIONS,
|
||||
@@ -10,17 +11,20 @@ import {
|
||||
import zhCNMessages from "@/i18n/locales/zh-CN.json";
|
||||
import enUSMessages from "@/i18n/locales/en-US.json";
|
||||
import jaJPMessages from "@/i18n/locales/ja-JP.json";
|
||||
import ruRUMessages from "@/i18n/locales/ru-RU.json";
|
||||
|
||||
const localeMessages = {
|
||||
"zh-CN": zhCNMessages,
|
||||
"en-US": enUSMessages,
|
||||
"ja-JP": jaJPMessages,
|
||||
"ru-RU": ruRUMessages,
|
||||
};
|
||||
|
||||
const languageLocaleMap = {
|
||||
zh: "zh-CN",
|
||||
en: "en-US",
|
||||
ja: "ja-JP",
|
||||
ru: "ru-RU",
|
||||
};
|
||||
|
||||
function isSupportedLocale(locale) {
|
||||
@@ -143,6 +147,7 @@ class LocalizedText extends String {
|
||||
|
||||
const currentLocale = ref(resolveInitialLocale());
|
||||
applyLocaleToDocument(currentLocale.value);
|
||||
Events.Emit("locale:changed", currentLocale.value);
|
||||
|
||||
const localizedCache = new Map();
|
||||
|
||||
@@ -155,6 +160,7 @@ export function setLocale(locale) {
|
||||
currentLocale.value = nextLocale;
|
||||
persistManualLocale(nextLocale);
|
||||
applyLocaleToDocument(nextLocale);
|
||||
Events.Emit("locale:changed", nextLocale);
|
||||
return nextLocale;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
import { Browser, Window } from "@wailsio/runtime";
|
||||
import LocaleSelect from "@/components/LocaleSelect.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import {
|
||||
getFooterAuthorInfo,
|
||||
openFooterAuthorHome,
|
||||
} from "@/services/clientApi";
|
||||
import {
|
||||
appState,
|
||||
checkForAppUpdates,
|
||||
@@ -14,7 +9,7 @@ import {
|
||||
updateViewState,
|
||||
} from "@/state/appState";
|
||||
import { isWindows } from "@/utils/isWindows";
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import Logo from "@/assets/logo.png";
|
||||
|
||||
@@ -24,7 +19,8 @@ const showIcon = computed(() => route.meta.showIcon !== false);
|
||||
const title = computed(() => route.meta.title ?? "Cursor助手|永久免费|自定义API");
|
||||
const directlyClose = computed(() => route.meta.directlyClose === true);
|
||||
const showFooter = computed(() => route.path === "/");
|
||||
const footerAuthorInfo = ref(null);
|
||||
const AUTHOR_REPOSITORY_URL = "https://github.com/leookun/cursor-byok";
|
||||
const AUTHOR_LABEL = "@leookun";
|
||||
const usageDocsURL = "https://docs.leokun.cn";
|
||||
let proxyStateTimer = null;
|
||||
const proxyStatePollIntervalMs = 10000;
|
||||
@@ -78,7 +74,7 @@ async function handleCheckForUpdates() {
|
||||
if (updateViewState.footerBusy || updateViewState.footerDownloading) {
|
||||
return;
|
||||
}
|
||||
const loadingMessageID = message.loading("检查更新中...");
|
||||
const loadingMessageID = message("检查更新中...", { duration: 0 });
|
||||
try {
|
||||
await checkForAppUpdates();
|
||||
} finally {
|
||||
@@ -88,41 +84,16 @@ async function handleCheckForUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFooterAuthorInfo() {
|
||||
try {
|
||||
footerAuthorInfo.value = await getFooterAuthorInfo();
|
||||
} catch (error) {
|
||||
console.error("[MainLayout] 加载作者信息失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function showActionError(title, error) {
|
||||
await showModal({
|
||||
title,
|
||||
content: String(error || "操作失败").trim() || "操作失败",
|
||||
confirmText: "确定",
|
||||
showCancel: false,
|
||||
});
|
||||
function showActionError(title, error) {
|
||||
const detail = String(error || "操作失败").trim() || "操作失败";
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
async function handleOpenAuthorHome() {
|
||||
if (!footerAuthorInfo.value) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await showModal({
|
||||
title: footerAuthorInfo.value.dialogTitle,
|
||||
content: footerAuthorInfo.value.dialogContent,
|
||||
confirmText: footerAuthorInfo.value.dialogConfirmText,
|
||||
cancelText: footerAuthorInfo.value.dialogCancelText,
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openFooterAuthorHome();
|
||||
await Browser.OpenURL(AUTHOR_REPOSITORY_URL);
|
||||
} catch (error) {
|
||||
await showActionError("打开主页失败", error);
|
||||
showActionError("打开作者地址失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,12 +101,11 @@ async function handleOpenUsageDocs() {
|
||||
try {
|
||||
await Browser.OpenURL(usageDocsURL);
|
||||
} catch (error) {
|
||||
await showActionError("打开使用教程失败", error);
|
||||
showActionError("打开使用教程失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFooterAuthorInfo();
|
||||
proxyStateTimer = window.setInterval(() => {
|
||||
if (showFooter.value) {
|
||||
void syncServiceState().catch(() => {});
|
||||
@@ -164,7 +134,7 @@ onUnmounted(() => {
|
||||
:class="{ '!justify-center': !isWindows }"
|
||||
>
|
||||
<div class="center-row gap-2" style="font-family: var(--font-num);">
|
||||
<img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" />
|
||||
<!-- <img v-if="showIcon" :src="Logo" class="w-[18px] h-[18px]" /> -->
|
||||
<div>{{ title }}</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -192,7 +162,7 @@ onUnmounted(() => {
|
||||
|
||||
<footer
|
||||
v-if="showFooter"
|
||||
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] border-t border-[#242424] px-[14px] text-[12px] text-[#8f8f8f]"
|
||||
class="flex !pr-1 h-[30px] shrink-0 items-center gap-[8px] px-[14px] text-[12px] text-[#8f8f8f]"
|
||||
>
|
||||
<div
|
||||
v-if="proxyBadgeText"
|
||||
@@ -221,13 +191,12 @@ onUnmounted(() => {
|
||||
<span>使用教程</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="footerAuthorInfo"
|
||||
type="button"
|
||||
class="center-row shrink-0 gap-[6px] cursor-pointer rounded-[6px] px-[6px] py-[3px] transition-colors duration-150 hover:bg-[#1f1f1f] hover:text-[#e5e5e5]"
|
||||
@click="handleOpenAuthorHome"
|
||||
>
|
||||
<span class="icon-[ant-design--bilibili-outlined] text-[14px]"></span>
|
||||
<span>{{ footerAuthorInfo.buttonText }}</span>
|
||||
<span class="icon-[mdi--github] text-[14px]"></span>
|
||||
<span>{{ AUTHOR_LABEL }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="updateViewState.footerDownloading"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import Home from "@/views/Home.vue";
|
||||
import ModelConfig from "@/views/ModelConfig.vue";
|
||||
import ModelEditor from "@/views/ModelEditor.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@@ -16,11 +15,6 @@ const router = createRouter({
|
||||
component: ModelConfig,
|
||||
meta: { showIcon: false, title: "模型配置", directlyClose: true },
|
||||
},
|
||||
{
|
||||
path: "/model-editor",
|
||||
component: ModelEditor,
|
||||
meta: { showIcon: false, title: "模型编辑", directlyClose: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
DisconnectCursorAccount,
|
||||
GetCursorAccountStatus,
|
||||
GetState,
|
||||
LoadUserConfig,
|
||||
SaveUserConfig,
|
||||
StartCursorAccountLogin,
|
||||
StartProxy,
|
||||
StopProxy,
|
||||
} from "@bindings/cursor/internal/bridge/proxyservice.js";
|
||||
@@ -15,12 +18,10 @@ import {
|
||||
GetAppVersion,
|
||||
GetFooterAuthorInfo,
|
||||
InstallReadyUpdate,
|
||||
GetModelEditorContext,
|
||||
OpenConfigWindow,
|
||||
OpenFooterAuthorHome,
|
||||
OpenHistoryWindow,
|
||||
OpenModelConfigWindow,
|
||||
OpenModelEditorWindow,
|
||||
} from "@bindings/cursor/internal/bridge/windowservice.js";
|
||||
import { Call } from "@wailsio/runtime";
|
||||
|
||||
@@ -62,6 +63,18 @@ export function saveUserConfig(payload) {
|
||||
return withApiLogging("SaveUserConfig", payload, () => SaveUserConfig(payload));
|
||||
}
|
||||
|
||||
export function getCursorAccountStatus() {
|
||||
return withApiLogging("GetCursorAccountStatus", undefined, () => GetCursorAccountStatus());
|
||||
}
|
||||
|
||||
export function startCursorAccountLogin() {
|
||||
return withApiLogging("StartCursorAccountLogin", undefined, () => StartCursorAccountLogin());
|
||||
}
|
||||
|
||||
export function disconnectCursorAccount() {
|
||||
return withApiLogging("DisconnectCursorAccount", undefined, () => DisconnectCursorAccount());
|
||||
}
|
||||
|
||||
export function getProxyState() {
|
||||
return withApiLogging("GetState", undefined, () => GetState());
|
||||
}
|
||||
@@ -118,16 +131,6 @@ export function openModelConfig() {
|
||||
return withApiLogging("OpenModelConfigWindow", undefined, () => OpenModelConfigWindow());
|
||||
}
|
||||
|
||||
export function openModelEditor(index, adapterJSON) {
|
||||
return withApiLogging("OpenModelEditorWindow", { index, adapterJSON }, () =>
|
||||
OpenModelEditorWindow(index, adapterJSON),
|
||||
);
|
||||
}
|
||||
|
||||
export function getModelEditorContext() {
|
||||
return withApiLogging("GetModelEditorContext", undefined, () => GetModelEditorContext());
|
||||
}
|
||||
|
||||
export function testModelAdapter(adapter) {
|
||||
return Call.ByName(`${PROXY_SERVICE_NAME}.TestModelAdapter`, adapter).then(
|
||||
(result) => {
|
||||
@@ -146,3 +149,9 @@ export function getModelAdapterTestResults() {
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.GetModelAdapterTestResults`),
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchModelAdapterModels(payload) {
|
||||
return withApiLogging("FetchModelAdapterModels", payload, () =>
|
||||
Call.ByName(`${PROXY_SERVICE_NAME}.FetchModelAdapterModels`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
+152
-104
@@ -12,21 +12,25 @@ import {
|
||||
loadUserConfig,
|
||||
openLogsDirectory,
|
||||
openModelConfig,
|
||||
openModelEditor,
|
||||
saveUserConfig,
|
||||
startProxyService,
|
||||
stopProxyService,
|
||||
testModelAdapter,
|
||||
fetchModelAdapterModels,
|
||||
} from "@/services/clientApi";
|
||||
import {
|
||||
normalizeReasoningEffort,
|
||||
SUPPORTED_REASONING_EFFORTS,
|
||||
} from "@/state/modelAdapterReasoning";
|
||||
|
||||
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_ENDPOINT_CUSTOM = "/custom";
|
||||
export const OPENAI_EXTRA_PARAMS_DEFAULT_JSON = `{
|
||||
"service_tier": "priority"
|
||||
}`;
|
||||
@@ -34,8 +38,7 @@ 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 SUPPORTED_OPENAI_ENDPOINTS = new Set([OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_CHAT_COMPLETIONS, OPENAI_ENDPOINT_CUSTOM]);
|
||||
const PROXY_STATE_EVENT = "proxy:state";
|
||||
const USER_CONFIG_CHANGED_EVENT = "user-config:changed";
|
||||
const UPDATE_STATE_EVENT = "update:state";
|
||||
@@ -46,11 +49,6 @@ 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();
|
||||
@@ -125,14 +123,6 @@ function formatReleaseDate(value) {
|
||||
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) {
|
||||
@@ -178,7 +168,7 @@ export function buildModelAdapterTestRequestHash(source) {
|
||||
normalizeBaseURL(adapter.baseURL),
|
||||
asString(adapter.apiKey),
|
||||
asString(adapter.modelID),
|
||||
adapter.type === "openai" ? asString(adapter.reasoningEffort || "medium") : "",
|
||||
adapter.type === "openai" ? asString(adapter.reasoningEffort) : "",
|
||||
adapter.type === "openai" ? normalizeOpenAIEndpoint(adapter.openAIEndpoint) : "",
|
||||
adapter.type === "openai" ? String(Boolean(adapter.openAIExtraParamsEnabled)) : "false",
|
||||
adapter.type === "openai" && adapter.openAIExtraParamsEnabled ? asString(adapter.openAIExtraParamsJSON) : "",
|
||||
@@ -239,7 +229,7 @@ function normalizeModelAdapterTestResult(source) {
|
||||
rawResponse: asString(raw.rawResponse),
|
||||
testedAt: asString(raw.testedAt),
|
||||
};
|
||||
if (!normalized.summaryText) {
|
||||
if (status === "running" || status === "success") {
|
||||
normalized.summaryText = formatModelAdapterTestSummary(normalized);
|
||||
}
|
||||
if (status === "error" && !normalized.summaryText) {
|
||||
@@ -260,13 +250,14 @@ function normalizeModelAdapterTestResults(source) {
|
||||
export function createEmptyModelAdapter() {
|
||||
return {
|
||||
id: "",
|
||||
sort: 0,
|
||||
displayName: "",
|
||||
type: "openai",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
tooltipData: "备注",
|
||||
modelID: "",
|
||||
reasoningEffort: "medium",
|
||||
reasoningEffort: "",
|
||||
openAIEndpoint: OPENAI_ENDPOINT_RESPONSES,
|
||||
openAIExtraParamsEnabled: false,
|
||||
openAIExtraParamsJSON: OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
|
||||
@@ -282,6 +273,9 @@ export function createEmptyModelAdapter() {
|
||||
};
|
||||
}
|
||||
|
||||
// normalizeOpenAIEndpoint 归一化 endpoint 路径。
|
||||
// 支持三个预设值:/v1/responses、/v1/chat/completions、/custom(自定义路径)。
|
||||
// 选 /custom 时,用户需在接口地址栏填写完整请求 URL。
|
||||
function normalizeOpenAIEndpoint(value) {
|
||||
const text = asString(value).toLowerCase();
|
||||
if (!text) {
|
||||
@@ -290,6 +284,10 @@ function normalizeOpenAIEndpoint(value) {
|
||||
return SUPPORTED_OPENAI_ENDPOINTS.has(text) ? text : "";
|
||||
}
|
||||
|
||||
function isValidOpenAIEndpoint(value) {
|
||||
return normalizeOpenAIEndpoint(value) !== "";
|
||||
}
|
||||
|
||||
function validateJSONObject(value, label) {
|
||||
const text = asString(value);
|
||||
if (!text) {
|
||||
@@ -334,7 +332,7 @@ function validateAnthropicExtraParamsJSON(value) {
|
||||
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 normalizedReasoningEffort = normalizeReasoningEffort(raw.reasoningEffort ?? raw.reasoning_effort);
|
||||
const normalizedAnthropicThinkingEffort = asString(
|
||||
raw.anthropicThinkingEffort
|
||||
?? raw.anthropic_thinking_effort
|
||||
@@ -360,15 +358,14 @@ export function normalizeModelAdapter(source) {
|
||||
: "";
|
||||
return {
|
||||
id: asString(raw.id),
|
||||
sort: asPositiveInteger(raw.sort),
|
||||
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",
|
||||
reasoningEffort: normalizedReasoningEffort,
|
||||
openAIEndpoint: normalizedType === "openai" ? normalizedOpenAIEndpoint : "",
|
||||
openAIExtraParamsEnabled,
|
||||
openAIExtraParamsJSON,
|
||||
@@ -397,7 +394,29 @@ export function normalizeModelAdapter(source) {
|
||||
}
|
||||
|
||||
export function normalizeModelAdapters(source) {
|
||||
return asArray(source).map((item) => normalizeModelAdapter(item));
|
||||
return asArray(source)
|
||||
.map((item, sourceIndex) => ({
|
||||
adapter: normalizeModelAdapter(item),
|
||||
sourceIndex,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
const leftSort = left.adapter.sort;
|
||||
const rightSort = right.adapter.sort;
|
||||
if (leftSort <= 0 && rightSort <= 0) {
|
||||
return left.sourceIndex - right.sourceIndex;
|
||||
}
|
||||
if (leftSort <= 0) {
|
||||
return 1;
|
||||
}
|
||||
if (rightSort <= 0) {
|
||||
return -1;
|
||||
}
|
||||
return leftSort - rightSort || left.sourceIndex - right.sourceIndex;
|
||||
})
|
||||
.map(({ adapter }, index) => ({
|
||||
...adapter,
|
||||
sort: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateModelAdapters(source) {
|
||||
@@ -405,9 +424,6 @@ export function validateModelAdapters(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`;
|
||||
}
|
||||
@@ -417,17 +433,29 @@ export function validateModelAdapters(source) {
|
||||
if (!adapter.apiKey) {
|
||||
return `${prefix} 的访问密钥不能为空`;
|
||||
}
|
||||
if (!adapter.tooltipData) {
|
||||
return `${prefix} 的悬停提示不能为空`;
|
||||
if (!adapter.displayName) {
|
||||
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.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
|
||||
return `${prefix} 的上下文窗口必须为正整数`;
|
||||
}
|
||||
if (adapter.type === "openai" && !SUPPORTED_OPENAI_ENDPOINTS.has(adapter.openAIEndpoint)) {
|
||||
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses 或 /v1/chat/completions`;
|
||||
if (adapter.type === "openai" && !SUPPORTED_REASONING_EFFORTS.has(adapter.reasoningEffort)) {
|
||||
return `${prefix} 的推理强度仅支持不设置、low、medium、high、xhigh、max`;
|
||||
}
|
||||
if (adapter.type === "anthropic" && adapter.anthropicMaxTokens && (!Number.isInteger(adapter.anthropicMaxTokens) || adapter.anthropicMaxTokens <= 0)) {
|
||||
return `${prefix} 的最大输出 Token 必须为正整数`;
|
||||
}
|
||||
if (adapter.type === "anthropic" && !SUPPORTED_ANTHROPIC_THINKING_EFFORTS.has(adapter.anthropicThinkingEffort)) {
|
||||
return `${prefix} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max`;
|
||||
}
|
||||
if (adapter.type === "openai" && adapter.maxCompletionTokens && (!Number.isInteger(adapter.maxCompletionTokens) || adapter.maxCompletionTokens <= 0)) {
|
||||
return `${prefix} 的最大输出 Token 必须为正整数`;
|
||||
}
|
||||
if (adapter.type === "openai" && !isValidOpenAIEndpoint(adapter.openAIEndpoint)) {
|
||||
return `${prefix} 的 OpenAI 端点仅支持 /v1/responses、/v1/chat/completions 或以 / 开头的自定义路径`;
|
||||
}
|
||||
if (adapter.type === "openai" && adapter.openAIExtraParamsEnabled) {
|
||||
const extraParamsError = validateOpenAIExtraParamsJSON(adapter.openAIExtraParamsJSON);
|
||||
@@ -435,29 +463,20 @@ export function validateModelAdapters(source) {
|
||||
return `${prefix} 的 ${extraParamsError}`;
|
||||
}
|
||||
}
|
||||
if (adapter.type === "anthropic" && adapter.anthropicExtraParamsEnabled) {
|
||||
const extraParamsError = validateAnthropicExtraParamsJSON(adapter.anthropicExtraParamsJSON);
|
||||
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.tooltipData) {
|
||||
return `${prefix} 的悬停提示不能为空`;
|
||||
}
|
||||
if (adapter.thinkingBudgetTokens && (!Number.isInteger(adapter.thinkingBudgetTokens) || adapter.thinkingBudgetTokens <= 0)) {
|
||||
return `${prefix} 的思考预算 Token 必须为正整数`;
|
||||
@@ -471,13 +490,6 @@ export function validateModelAdapters(source) {
|
||||
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";
|
||||
}
|
||||
@@ -523,7 +535,6 @@ function loadCachedState() {
|
||||
|
||||
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),
|
||||
@@ -531,9 +542,6 @@ function normalizeConfig(source) {
|
||||
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),
|
||||
},
|
||||
@@ -576,7 +584,6 @@ function buildConfigPayload(source = appState) {
|
||||
backendListenAddr: normalized.backendListenAddr,
|
||||
proxyListenAddr: normalized.proxyListenAddr,
|
||||
modelAdapters: normalized.modelAdapters.map(({ id, ...adapter }) => adapter),
|
||||
routing: normalized.routing,
|
||||
homeMetrics: normalized.homeMetrics,
|
||||
lastAgentModelHash: normalized.lastAgentModelHash,
|
||||
};
|
||||
@@ -591,7 +598,6 @@ function applyConfigToState(config, { modelAdaptersOnly = false } = {}) {
|
||||
appState.modelAdapters = normalized.modelAdapters;
|
||||
appState.configBackendListenAddr = normalized.backendListenAddr;
|
||||
appState.configProxyListenAddr = normalized.proxyListenAddr;
|
||||
appState.routingMode = normalized.routing.mode;
|
||||
appState.includeCacheWriteInHitRate = normalized.homeMetrics.includeCacheWriteInHitRate;
|
||||
return normalized;
|
||||
}
|
||||
@@ -602,13 +608,6 @@ async function loadPersistedUserConfig() {
|
||||
|
||||
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 {
|
||||
@@ -822,7 +821,6 @@ export const appState = reactive({
|
||||
modelAdapterTestResults: {},
|
||||
configBackendListenAddr: cachedConfig.backendListenAddr,
|
||||
configProxyListenAddr: cachedConfig.proxyListenAddr,
|
||||
routingMode: cachedConfig.routing.mode,
|
||||
includeCacheWriteInHitRate: cachedConfig.homeMetrics.includeCacheWriteInHitRate,
|
||||
|
||||
serviceRunning: asBoolean(cachedState.serviceRunning),
|
||||
@@ -987,6 +985,29 @@ export const appViewState = reactive({
|
||||
}),
|
||||
});
|
||||
|
||||
function localizeUpdateMessage(msg) {
|
||||
if (!msg) return "";
|
||||
if (/当前已是最新版本/.test(msg)) {
|
||||
const match = msg.match(/v?([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
const version = match ? match[1] : appState.appVersion || "...";
|
||||
return `当前已是最新版本(v${version})。`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function localizeReadyContent() {
|
||||
const version = appState.updateVersion || appState.appVersion || "...";
|
||||
const date = formatReleaseDate(appState.updateReleaseDate);
|
||||
const notes = appState.updateReleaseNotes || "";
|
||||
|
||||
return [
|
||||
`版本:v${version}`,
|
||||
`发布时间:${date}`,
|
||||
"",
|
||||
notes || "无更新说明",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const updateViewState = reactive({
|
||||
footerDownloading: computed(() => appState.updateState === "downloading"),
|
||||
footerBusy: computed(() => ["checking", "installing"].includes(appState.updateState)),
|
||||
@@ -1008,24 +1029,25 @@ export const updateViewState = reactive({
|
||||
promptContent: computed(() => {
|
||||
switch (appState.updatePromptKind) {
|
||||
case "ready":
|
||||
return [
|
||||
`版本:v${appState.updateVersion || appState.appVersion || "..."}`,
|
||||
`发布时间:${formatReleaseDate(appState.updateReleaseDate)}`,
|
||||
"",
|
||||
appState.updateReleaseNotes || "无更新说明",
|
||||
].join("\n");
|
||||
return localizeReadyContent();
|
||||
case "error":
|
||||
return appState.updateError || appState.updateMessage || GENERIC_SERVICE_ERROR;
|
||||
return appState.updateError || localizeUpdateMessage(appState.updateMessage) || GENERIC_SERVICE_ERROR;
|
||||
default:
|
||||
return appState.updateMessage || `当前已是最新版本(v${appState.appVersion || "..."})。`;
|
||||
return localizeUpdateMessage(appState.updateMessage) || localizeUpdateMessage(`当前已是最新版本(v${appState.appVersion || "..."})。`);
|
||||
}
|
||||
}),
|
||||
promptConfirmText: computed(() =>
|
||||
appState.updatePromptKind === "ready" ? "立即重启更新" : "确定",
|
||||
),
|
||||
promptCancelText: computed(() =>
|
||||
appState.updatePromptKind === "ready" ? "稍后" : "取消",
|
||||
),
|
||||
promptConfirmText: computed(() => {
|
||||
if (appState.updatePromptKind === "ready") {
|
||||
return "立即重启更新";
|
||||
}
|
||||
return "确定";
|
||||
}),
|
||||
promptCancelText: computed(() => {
|
||||
if (appState.updatePromptKind === "ready") {
|
||||
return "稍后";
|
||||
}
|
||||
return "取消";
|
||||
}),
|
||||
promptShowCancel: computed(() => appState.updatePromptKind === "ready"),
|
||||
});
|
||||
|
||||
@@ -1065,6 +1087,10 @@ export async function refreshModelAdapterTestResults() {
|
||||
|
||||
export function startModelAdapterTest(adapter) {
|
||||
const normalized = normalizeModelAdapter(adapter);
|
||||
const validationError = validateModelAdapters([normalized]);
|
||||
if (validationError) {
|
||||
return Promise.reject(new Error(validationError));
|
||||
}
|
||||
return testModelAdapter(normalized).then((rawResult) => {
|
||||
const result = normalizeModelAdapterTestResult(rawResult);
|
||||
if (result.adapterID) {
|
||||
@@ -1086,9 +1112,6 @@ export async function persistUserConfig() {
|
||||
return persistConfigPayload({
|
||||
...currentConfig,
|
||||
modelAdapters: normalizeModelAdapters(appState.modelAdapters),
|
||||
routing: {
|
||||
mode: appState.routingMode,
|
||||
},
|
||||
homeMetrics: {
|
||||
...currentConfig.homeMetrics,
|
||||
includeCacheWriteInHitRate: appState.includeCacheWriteInHitRate,
|
||||
@@ -1114,16 +1137,6 @@ export async function saveIncludeCacheWriteInHitRate(value) {
|
||||
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);
|
||||
@@ -1159,6 +1172,13 @@ export async function saveModelAdapterAt(index, adapter) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAvailableModelIDs(payload) {
|
||||
const result = await fetchModelAdapterModels(payload);
|
||||
return asArray(result?.models)
|
||||
.map((item) => asString(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function deleteModelAdapterAt(index) {
|
||||
const currentConfig = await loadPersistedUserConfig();
|
||||
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
||||
@@ -1181,6 +1201,39 @@ export async function deleteModelAdapterAt(index) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveModelAdapterOrder(adapterIDs) {
|
||||
const orderedIDs = asArray(adapterIDs)
|
||||
.map((item) => asString(item))
|
||||
.filter(Boolean);
|
||||
const currentConfig = await loadPersistedUserConfig();
|
||||
const currentAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
|
||||
const adaptersByID = new Map(currentAdapters.map((adapter) => [adapter.id, adapter]));
|
||||
const uniqueIDs = new Set(orderedIDs);
|
||||
|
||||
if (
|
||||
orderedIDs.length !== currentAdapters.length
|
||||
|| uniqueIDs.size !== currentAdapters.length
|
||||
|| orderedIDs.some((id) => !adaptersByID.has(id))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "模型配置已发生变化,请刷新后重试",
|
||||
};
|
||||
}
|
||||
|
||||
const nextAdapters = orderedIDs.map((id, index) => ({
|
||||
...adaptersByID.get(id),
|
||||
sort: index + 1,
|
||||
}));
|
||||
return persistConfigPayload(
|
||||
{
|
||||
...currentConfig,
|
||||
modelAdapters: nextAdapters,
|
||||
},
|
||||
{ modelAdaptersOnly: true },
|
||||
);
|
||||
}
|
||||
|
||||
function splitDisplayNameSeed(value) {
|
||||
const text = asString(value);
|
||||
const match = text.match(/^(.*?)(?:\s*[-+](\d+))?$/);
|
||||
@@ -1324,11 +1377,6 @@ export async function openModelConfigWindow() {
|
||||
await openModelConfig();
|
||||
}
|
||||
|
||||
export async function openModelEditorWindow(index, adapter) {
|
||||
const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter));
|
||||
await openModelEditor(index, adapterJSON);
|
||||
}
|
||||
|
||||
export async function checkForAppUpdates() {
|
||||
await checkForUpdates();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const SUPPORTED_REASONING_EFFORTS = new Set(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
export function normalizeReasoningEffort(value) {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return value.toString().trim().toLowerCase();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value).trim().toLowerCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizeReasoningEffort,
|
||||
SUPPORTED_REASONING_EFFORTS,
|
||||
} from "./modelAdapterReasoning.js";
|
||||
|
||||
test("normalizeReasoningEffort preserves blank and supported values", () => {
|
||||
assert.equal(normalizeReasoningEffort(""), "");
|
||||
assert.equal(normalizeReasoningEffort(" HIGH "), "high");
|
||||
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalizeReasoningEffort("max")), true);
|
||||
});
|
||||
|
||||
test("normalizeReasoningEffort preserves unknown values for validation", () => {
|
||||
const normalized = normalizeReasoningEffort(" Unsupported ");
|
||||
|
||||
assert.equal(normalized, "unsupported");
|
||||
assert.equal(SUPPORTED_REASONING_EFFORTS.has(normalized), false);
|
||||
});
|
||||
@@ -13,8 +13,15 @@ body,
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: #191919;
|
||||
font-family: "PingFang-Medium", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: #F7F7F7;
|
||||
font-family:
|
||||
"PingFang-Medium",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
color: #f7f7f7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -28,10 +35,41 @@ body,
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(100, 100, 100, 0.8) transparent;
|
||||
}
|
||||
:root{
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--x-maskImage: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 0px,
|
||||
black 18px,
|
||||
black calc(100% - 0px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
--x-WebkitMaskImage: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 0px,
|
||||
black 18px,
|
||||
black calc(100% - 0px),
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
.scroll-shadow {
|
||||
-webkit-mask-image: var(--x-maskImage);
|
||||
mask-image: var(--x-maskImage);
|
||||
}
|
||||
.scroll-shadow-bottom {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0,
|
||||
black calc(100% - 16px),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
black 0,
|
||||
black calc(100% - 16px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* WebKit/Blink (Chrome, Safari, Edge, Opera) */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -2,44 +2,37 @@
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Card from "@/components/ui/Card.vue";
|
||||
import LocaleSelect from "@/components/LocaleSelect.vue";
|
||||
import Select from "@/components/ui/Select.vue";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import {
|
||||
appState,
|
||||
openModelConfigWindow,
|
||||
persistUserConfig,
|
||||
reloadUserConfig,
|
||||
ROUTE_MODE_OPTIONS,
|
||||
toUserError,
|
||||
} from "@/state/appState";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const routeModeOptions = ROUTE_MODE_OPTIONS;
|
||||
const message = useMessage();
|
||||
|
||||
async function showActionError(title, error) {
|
||||
await showModal({
|
||||
title,
|
||||
content: String(error || "服务错误").trim() || "服务错误",
|
||||
});
|
||||
function showActionError(title, error) {
|
||||
const detail = String(error || "服务错误").trim() || "服务错误";
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
const result = await persistUserConfig();
|
||||
if (!result.ok) {
|
||||
await showActionError("保存失败", result.error);
|
||||
showActionError("保存失败", result.error);
|
||||
return;
|
||||
}
|
||||
await showModal({
|
||||
title: "提示",
|
||||
content: "本地配置已保存",
|
||||
});
|
||||
message("本地配置已保存");
|
||||
}
|
||||
|
||||
async function handleOpenModelConfig() {
|
||||
try {
|
||||
await openModelConfigWindow();
|
||||
} catch (error) {
|
||||
await showActionError("打开失败", toUserError(error));
|
||||
showActionError("打开失败", toUserError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +48,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<h2 class="text-base font-medium text-white">本地配置</h2>
|
||||
<div class="text-sm text-[#a3a3a3]">
|
||||
可配置运行模式和模型渠道;运行日志位于 <code>~/.cursor-local-assistant-v2/logs/</code>
|
||||
可配置模型渠道;运行日志位于 <code>~/.cursor-local-assistant-v2/logs/</code>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" :disabled="appState.configSaving" @click="handleSaveConfig">
|
||||
@@ -64,24 +57,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-medium text-white">运行模式</h2>
|
||||
<div class="text-sm text-[#a3a3a3]">
|
||||
控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[220px] max-w-full">
|
||||
<Select
|
||||
v-model="appState.routingMode"
|
||||
:options="routeModeOptions"
|
||||
placeholder="选择模式"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
|
||||
+20
-38
@@ -1,17 +1,15 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Card from "@/components/ui/Card.vue";
|
||||
import Switch from "@/components/ui/Switch.vue";
|
||||
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
|
||||
import CursorAccountCard from "@/components/CursorAccountCard.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import { getAdRuntime } from "@/services/clientApi";
|
||||
import {
|
||||
appState,
|
||||
appViewState,
|
||||
openConfigWindow,
|
||||
openModelConfigWindow,
|
||||
saveRoutingMode,
|
||||
syncHomeMetrics,
|
||||
syncServiceState,
|
||||
toUserError,
|
||||
@@ -20,10 +18,9 @@ import {
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const directModeEnabled = computed(() => appState.routingMode === "upstream");
|
||||
const message = useMessage();
|
||||
const AD_UPDATED_EVENT = "ad:updated";
|
||||
const OPEN_AD_EVENT = "cursor:open-ad";
|
||||
const message = useMessage();
|
||||
|
||||
const adRuntime = ref(null);
|
||||
let unsubscribeAdUpdated = null;
|
||||
@@ -83,17 +80,15 @@ function handleOpenHomeAd(slotId) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_AD_EVENT, { detail: { slotId: asString(slotId) } }));
|
||||
}
|
||||
|
||||
async function showActionError(title, error) {
|
||||
await showModal({
|
||||
title,
|
||||
content: String(error || "服务错误").trim() || "服务错误",
|
||||
});
|
||||
function showActionError(title, error) {
|
||||
const detail = String(error || "服务错误").trim() || "服务错误";
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
async function handleToggleService() {
|
||||
const result = await toggleService();
|
||||
if (!result.ok) {
|
||||
await showActionError("服务操作失败", result.error);
|
||||
showActionError("服务操作失败", result.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,19 +98,24 @@ async function handleRefreshState() {
|
||||
syncHomeMetrics(),
|
||||
]);
|
||||
if (serviceStateResult.status === "rejected") {
|
||||
await showActionError("刷新失败", toUserError(serviceStateResult.reason));
|
||||
showActionError("刷新失败", toUserError(serviceStateResult.reason));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshMetrics() {
|
||||
await syncHomeMetrics().catch(() => {});
|
||||
const result = await syncHomeMetrics();
|
||||
if (result.ok) {
|
||||
message("刷新成功");
|
||||
return;
|
||||
}
|
||||
showActionError("刷新失败", result.error);
|
||||
}
|
||||
|
||||
async function handleOpenConfig() {
|
||||
try {
|
||||
await openConfigWindow();
|
||||
} catch (error) {
|
||||
await showActionError("打开失败", toUserError(error));
|
||||
showActionError("打开失败", toUserError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,19 +123,10 @@ async function handleOpenModelConfig() {
|
||||
try {
|
||||
await openModelConfigWindow();
|
||||
} catch (error) {
|
||||
await showActionError("打开失败", toUserError(error));
|
||||
showActionError("打开失败", toUserError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDirectModeChange(enabled) {
|
||||
const result = await saveRoutingMode(enabled ? "upstream" : "local");
|
||||
if (!result.ok) {
|
||||
await showActionError("切换失败", result.error);
|
||||
return;
|
||||
}
|
||||
message.success(enabled ? "已切换到直连 Cursor 模式" : "已切换到本地服务模式");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribeAdUpdated = Events.On(AD_UPDATED_EVENT, handleAdUpdated);
|
||||
void syncAdRuntimeQuietly();
|
||||
@@ -149,7 +140,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4 pt-0 text-[#e5e5e5]">
|
||||
<div class="flex h-full min-h-0 flex-col gap-4 overflow-y-auto scroll-shadow-bottom p-4 pt-0 text-[#e5e5e5]">
|
||||
<HomeMetricsCard
|
||||
:metrics="appState.homeMetrics"
|
||||
:loading="appState.homeMetricsLoading"
|
||||
@@ -161,7 +152,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Card>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="center-row justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm" :class="appViewState.serviceStatusClass">
|
||||
{{ appViewState.serviceStatusText }}
|
||||
@@ -180,21 +171,12 @@ onBeforeUnmount(() => {
|
||||
class="rounded-[8px] border border-[#4b1d1d] bg-[#2a1313] px-3 py-2 text-sm text-[#fca5a5]">
|
||||
{{ appState.serviceLastError }}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
label="直连模式"
|
||||
description="开启后,Cursor将直接接通官方,请勿开启"
|
||||
enabled-text="当前为直连模式"
|
||||
disabled-text="当前为本地服务模式"
|
||||
:enabled="directModeEnabled"
|
||||
:busy="appState.configSaving"
|
||||
:disabled="appState.configSaving"
|
||||
@change="handleDirectModeChange"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CursorAccountCard />
|
||||
|
||||
<Card class="">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-medium text-white">本地配置</h2>
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Card from "@/components/ui/Card.vue";
|
||||
import ContentModal from "@/components/ui/ContentModal.vue";
|
||||
import ModelAdapterTestCard from "@/components/ModelAdapterTestCard.vue";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import ModelEditor from "@/components/ModelEditor.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import Sortable from "sortablejs";
|
||||
import {
|
||||
appState,
|
||||
createEmptyModelAdapter,
|
||||
deleteModelAdapterAt,
|
||||
duplicateModelAdapterAt,
|
||||
getModelAdapterTestResultByID,
|
||||
openModelEditorWindow,
|
||||
reloadUserConfig,
|
||||
runModelAdapterTest,
|
||||
saveModelAdapterOrder,
|
||||
startModelAdapterTest,
|
||||
toUserError,
|
||||
} from "@/state/appState";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
const BATCH_TEST_CONCURRENCY = 10;
|
||||
const message = useMessage();
|
||||
|
||||
const typeTabs = [
|
||||
{ label: "全部", value: "all", icon: "icon-[mdi--view-grid-outline]" },
|
||||
{ label: "OpenAI", value: "openai", icon: "icon-[bxl--openai]" },
|
||||
{ label: "Anthropic", value: "anthropic", icon: "icon-[logos--claude-icon]" },
|
||||
];
|
||||
|
||||
const activeType = ref("openai");
|
||||
const activeType = ref("all");
|
||||
const batchTesting = ref(false);
|
||||
const batchStopping = ref(false);
|
||||
const batchTotal = ref(0);
|
||||
const batchCompleted = ref(0);
|
||||
const editorOpen = ref(false);
|
||||
const editorIndex = ref(-1);
|
||||
const editorAdapter = ref(null);
|
||||
const editorSession = ref(0);
|
||||
const modelGrid = ref(null);
|
||||
const sortSaving = ref(false);
|
||||
const batchActiveCalls = new Set();
|
||||
let batchStopRequested = false;
|
||||
let sortable = null;
|
||||
|
||||
const filteredAdapters = computed(() =>
|
||||
appState.modelAdapters.filter((adapter) => adapter.type === activeType.value),
|
||||
const filteredAdapters = computed(() => (
|
||||
activeType.value === "all"
|
||||
? appState.modelAdapters
|
||||
: appState.modelAdapters.filter((adapter) => adapter.type === activeType.value)
|
||||
));
|
||||
const filteredAdapterOrderKey = computed(() =>
|
||||
filteredAdapters.value.map((adapter) => adapter.id).join("\n"),
|
||||
);
|
||||
const batchButtonText = computed(() => {
|
||||
if (batchStopping.value) {
|
||||
@@ -44,24 +61,30 @@ const batchButtonText = computed(() => {
|
||||
}
|
||||
return `停止测试 ${batchCompleted.value}/${batchTotal.value}`;
|
||||
});
|
||||
const editorTitle = computed(() => (editorIndex.value >= 0 ? "编辑模型配置" : "新增模型配置"));
|
||||
const emptyStateText = computed(() => (
|
||||
activeType.value === "all"
|
||||
? "当前还没有配置任何模型。"
|
||||
: `当前还没有配置任何 ${typeLabel(activeType.value)} 模型。`
|
||||
));
|
||||
|
||||
watch(
|
||||
() => appState.modelAdapters,
|
||||
(adapters) => {
|
||||
if (adapters.some((adapter) => adapter.type === activeType.value)) {
|
||||
if (
|
||||
activeType.value === "all"
|
||||
|| adapters.some((adapter) => adapter.type === activeType.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const fallback = typeTabs.find((tab) => adapters.some((adapter) => adapter.type === tab.value));
|
||||
activeType.value = fallback?.value ?? "openai";
|
||||
activeType.value = "all";
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
async function showActionError(title, error) {
|
||||
await showModal({
|
||||
title,
|
||||
content: String(error || "服务错误").trim() || "服务错误",
|
||||
});
|
||||
function showActionError(title, error) {
|
||||
const detail = String(error || "服务错误").trim() || "服务错误";
|
||||
message(`${title}:${detail}`);
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
@@ -92,41 +115,167 @@ function formatHost(value) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditor(index = -1) {
|
||||
const adapter = index >= 0
|
||||
function openEditor(index = -1) {
|
||||
editorIndex.value = index;
|
||||
editorAdapter.value = index >= 0
|
||||
? appState.modelAdapters[index]
|
||||
: {
|
||||
...createEmptyModelAdapter(),
|
||||
type: activeType.value,
|
||||
type: activeType.value === "anthropic" ? "anthropic" : "openai",
|
||||
};
|
||||
try {
|
||||
await openModelEditorWindow(index, adapter);
|
||||
} catch (error) {
|
||||
await showActionError("打开失败", toUserError(error));
|
||||
editorSession.value += 1;
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (appState.configSaving) {
|
||||
return;
|
||||
}
|
||||
editorOpen.value = false;
|
||||
}
|
||||
|
||||
function handleEditorSaved(adapter) {
|
||||
if (activeType.value !== "all" && adapter?.type) {
|
||||
activeType.value = adapter.type;
|
||||
}
|
||||
}
|
||||
|
||||
function destroySortable() {
|
||||
if (!sortable) {
|
||||
return;
|
||||
}
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
|
||||
function syncSortable() {
|
||||
const element = modelGrid.value;
|
||||
if (!element) {
|
||||
destroySortable();
|
||||
return;
|
||||
}
|
||||
if (!sortable || sortable.el !== element) {
|
||||
destroySortable();
|
||||
sortable = Sortable.create(element, {
|
||||
animation: 160,
|
||||
dataIdAttr: "data-model-id",
|
||||
draggable: ".model-sort-item",
|
||||
handle: ".model-sort-handle",
|
||||
ghostClass: "opacity-40",
|
||||
chosenClass: "!border-[#10AD5D]",
|
||||
dragClass: "cursor-grabbing",
|
||||
onEnd: (event) => {
|
||||
void handleModelSort(event);
|
||||
},
|
||||
});
|
||||
}
|
||||
sortable.option(
|
||||
"disabled",
|
||||
sortSaving.value || appState.configSaving || batchTesting.value,
|
||||
);
|
||||
sortable.sort(filteredAdapters.value.map((adapter) => adapter.id), false);
|
||||
}
|
||||
|
||||
async function restoreModelOrder(previousAdapters) {
|
||||
try {
|
||||
await reloadUserConfig({ modelAdaptersOnly: true });
|
||||
} catch (_error) {
|
||||
appState.modelAdapters = previousAdapters;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModelSort(event) {
|
||||
const oldIndex = event.oldDraggableIndex ?? event.oldIndex;
|
||||
const newIndex = event.newDraggableIndex ?? event.newIndex;
|
||||
if (
|
||||
!Number.isInteger(oldIndex)
|
||||
|| !Number.isInteger(newIndex)
|
||||
|| oldIndex === newIndex
|
||||
) {
|
||||
syncSortable();
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedTypeAdapters = filteredAdapters.value.slice();
|
||||
const [movedAdapter] = reorderedTypeAdapters.splice(oldIndex, 1);
|
||||
if (!movedAdapter || newIndex < 0 || newIndex > reorderedTypeAdapters.length) {
|
||||
syncSortable();
|
||||
return;
|
||||
}
|
||||
reorderedTypeAdapters.splice(newIndex, 0, movedAdapter);
|
||||
|
||||
const previousAdapters = appState.modelAdapters.slice();
|
||||
let nextAdapters = reorderedTypeAdapters;
|
||||
if (activeType.value !== "all") {
|
||||
let typeIndex = 0;
|
||||
nextAdapters = previousAdapters.map((adapter) => {
|
||||
if (adapter.type !== activeType.value) {
|
||||
return adapter;
|
||||
}
|
||||
const nextAdapter = reorderedTypeAdapters[typeIndex];
|
||||
typeIndex += 1;
|
||||
return nextAdapter;
|
||||
});
|
||||
}
|
||||
nextAdapters = nextAdapters
|
||||
.map((adapter, index) => ({
|
||||
...adapter,
|
||||
sort: index + 1,
|
||||
}));
|
||||
|
||||
sortSaving.value = true;
|
||||
appState.modelAdapters = nextAdapters;
|
||||
try {
|
||||
const result = await saveModelAdapterOrder(nextAdapters.map((adapter) => adapter.id));
|
||||
if (!result.ok) {
|
||||
await restoreModelOrder(previousAdapters);
|
||||
showActionError("排序失败", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
await restoreModelOrder(previousAdapters);
|
||||
showActionError("排序失败", toUserError(error));
|
||||
} finally {
|
||||
sortSaving.value = false;
|
||||
await nextTick();
|
||||
syncSortable();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
modelGrid.value,
|
||||
activeType.value,
|
||||
filteredAdapterOrderKey.value,
|
||||
appState.configSaving,
|
||||
batchTesting.value,
|
||||
],
|
||||
() => {
|
||||
void nextTick().then(syncSortable);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
async function handleDeleteModelAdapter(index) {
|
||||
const target = appState.modelAdapters[index];
|
||||
if (!target) {
|
||||
await showActionError("删除失败", "模型配置不存在,无法删除");
|
||||
showActionError("删除失败", "模型配置不存在,无法删除");
|
||||
return;
|
||||
}
|
||||
const result = await deleteModelAdapterAt(index);
|
||||
if (!result.ok) {
|
||||
await showActionError("删除失败", result.error);
|
||||
showActionError("删除失败", result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicateModelAdapter(index) {
|
||||
const target = appState.modelAdapters[index];
|
||||
if (!target) {
|
||||
await showActionError("复制失败", "模型配置不存在,无法复制");
|
||||
showActionError("复制失败", "模型配置不存在,无法复制");
|
||||
return;
|
||||
}
|
||||
const result = await duplicateModelAdapterAt(index);
|
||||
if (!result.ok) {
|
||||
await showActionError("复制失败", result.error);
|
||||
showActionError("复制失败", result.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,17 +360,20 @@ async function handleTestAllModelAdapters() {
|
||||
|
||||
onMounted(async () => {
|
||||
await reloadUserConfig({ modelAdaptersOnly: true }).catch(() => { });
|
||||
await nextTick();
|
||||
syncSortable();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void stopBatchTesting();
|
||||
destroySortable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col p-4 pt-0 text-[#e5e5e5] overflow-hidden">
|
||||
<div class="flex h-full min-h-0 flex-col pt-0 text-[#e5e5e5] overflow-hidden">
|
||||
<div class="shrink-0 pb-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center justify-between gap-4 px-4">
|
||||
<div class="center-row gap-2">
|
||||
<button
|
||||
v-for="tab in typeTabs"
|
||||
@@ -240,37 +392,49 @@ onBeforeUnmount(() => {
|
||||
<div class="center-row gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
:disabled="appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
|
||||
:disabled="sortSaving || appState.configSaving || (!batchTesting && filteredAdapters.length === 0)"
|
||||
@click="handleTestAllModelAdapters"
|
||||
>
|
||||
{{ batchButtonText }}
|
||||
</Button>
|
||||
<Button variant="primary" :disabled="appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
|
||||
<Button variant="primary" :disabled="sortSaving || appState.configSaving || batchTesting" @click="openEditor()">新增模型</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<div class="min-h-0 flex-1 ">
|
||||
<div v-if="filteredAdapters.length === 0"
|
||||
class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] border border-dashed border-[#3a3a3a] bg-[#232323] px-4 text-sm text-[#a3a3a3]">
|
||||
当前还没有配置任何 {{ typeLabel(activeType) }} 模型。
|
||||
class="flex h-full min-h-[220px] items-center justify-center rounded-[8px] px-4 text-sm text-[#a3a3a3]">
|
||||
{{ emptyStateText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="h-full min-h-0 overflow-y-auto pr-1">
|
||||
<div class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]">
|
||||
<div v-else class="h-full min-h-0 overflow-y-auto scroll-shadow-bottom p-4 pt-0">
|
||||
<div
|
||||
ref="modelGrid"
|
||||
class="grid gap-3 pb-1 [grid-template-columns:repeat(auto-fill,minmax(250px,1fr))]"
|
||||
>
|
||||
<Card
|
||||
v-for="(adapter, index) in filteredAdapters"
|
||||
:key="adapter.id || `${adapter.baseURL}-${adapter.modelID}-${index}`"
|
||||
class="model-sort-item group relative pb-2"
|
||||
:data-model-id="adapter.id"
|
||||
>
|
||||
<div class="flex h-full min-h-[154px] flex-col justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="model-sort-handle w-[30px] h-[30px] center-row justify-center absolute left-2 top-2 z-10 shrink-0 touch-none cursor-grab rounded-[6px] border border-transparent bg-transparent text-transparent opacity-0 outline-none transition-[opacity,color,border-color,background-color] focus-visible:border-[#10AD5D] focus-visible:bg-[#333333] focus-visible:text-white focus-visible:opacity-100 active:cursor-grabbing group-hover:border-[#454545] group-hover:bg-[#333333] group-hover:text-white group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
:disabled="sortSaving || appState.configSaving || batchTesting"
|
||||
aria-label="拖拽排序"
|
||||
title="拖拽排序"
|
||||
@click.stop
|
||||
>
|
||||
<span class="icon-[icon-park-outline--drag] text-[20px]"></span>
|
||||
</button>
|
||||
<div class="flex h-[150px] flex-col justify-between gap-3">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="min-w-0 flex-1 ">
|
||||
<div class="truncate text-base font-medium text-white">{{ adapter.displayName }}</div>
|
||||
<div class="mt-1 truncate text-sm text-[#8f8f8f]">{{ adapter.modelID }}</div>
|
||||
<div v-if="adapter.type === 'openai'" class="mt-0.5 truncate text-xs text-[#737373]">
|
||||
{{ adapter.openAIEndpoint || "/v1/responses" }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="center-row shrink-0 gap-1 rounded-[999px] border border-[#3f3f3f] px-[7px] py-[4px] text-[11px] font-medium text-[#cfcfcf]"
|
||||
@@ -281,17 +445,6 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-sm text-[#a3a3a3]">
|
||||
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">Host</div>
|
||||
<div class="mt-1 truncate text-[#d4d4d4]" :title="adapter.baseURL">{{ formatHost(adapter.baseURL) }}</div>
|
||||
</div>
|
||||
<div class="rounded-[8px] bg-[#232323] px-3 py-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.08em] text-[#666]">API Key</div>
|
||||
<div class="mt-1 truncate text-[#d4d4d4]">{{ maskSecret(adapter.apiKey) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelAdapterTestCard
|
||||
compact
|
||||
title="测试"
|
||||
@@ -300,17 +453,17 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="center-row flex-wrap justify-end gap-2 border-t border-[#343434] pt-3">
|
||||
<div class="center-row flex-wrap justify-end gap-2 pt-0">
|
||||
<Button
|
||||
variant="default"
|
||||
:disabled="appState.configSaving || batchTesting || isAdapterTesting(adapter)"
|
||||
:disabled="sortSaving || appState.configSaving || batchTesting || isAdapterTesting(adapter)"
|
||||
@click="handleTestModelAdapter(adapter)"
|
||||
>
|
||||
{{ isAdapterTesting(adapter) ? "测试中..." : "测试" }}
|
||||
</Button>
|
||||
<Button variant="default" :disabled="appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
|
||||
<Button variant="default" :disabled="appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
|
||||
<Button variant="text" :disabled="appState.configSaving"
|
||||
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="openEditor(appState.modelAdapters.indexOf(adapter))">编辑</Button>
|
||||
<Button variant="default" :disabled="sortSaving || appState.configSaving" @click="handleDuplicateModelAdapter(appState.modelAdapters.indexOf(adapter))">复制</Button>
|
||||
<Button variant="text" :disabled="sortSaving || appState.configSaving"
|
||||
@click="handleDeleteModelAdapter(appState.modelAdapters.indexOf(adapter))">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,4 +472,21 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentModal
|
||||
:open="editorOpen"
|
||||
:title="editorTitle"
|
||||
size="xl"
|
||||
:close-disabled="appState.configSaving"
|
||||
@close="closeEditor"
|
||||
>
|
||||
<ModelEditor
|
||||
v-if="editorOpen && editorAdapter"
|
||||
:key="editorSession"
|
||||
:index="editorIndex"
|
||||
:adapter="editorAdapter"
|
||||
@saved="handleEditorSaved"
|
||||
@close="closeEditor"
|
||||
/>
|
||||
</ContentModal>
|
||||
</template>
|
||||
|
||||
@@ -1587,6 +1587,11 @@ semver@^6.3.1:
|
||||
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
sortablejs@^1.15.7:
|
||||
version "1.15.7"
|
||||
resolved "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz#83a0bddc472117ee328dea20b2e6f490fed20f86"
|
||||
integrity sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==
|
||||
|
||||
source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.1
|
||||
connectrpc.com/connect v1.19.1
|
||||
github.com/PuerkitoBio/goquery v1.9.2
|
||||
github.com/denisbrodbeck/machineid v1.0.1
|
||||
github.com/elazarl/goproxy v1.7.2
|
||||
github.com/firecrawl/html-to-markdown v0.0.0-20260312013131-1af9901a5d61
|
||||
@@ -29,7 +30,6 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 542 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 643 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 584 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 651 KiB |
+102
-23
@@ -8,7 +8,9 @@ import (
|
||||
"encoding/pem"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
goruntime "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +39,8 @@ const (
|
||||
appName = "Cursor助手"
|
||||
// adRefreshInterval 表示后台广告拉取间隔。
|
||||
adRefreshInterval = 3 * time.Minute
|
||||
// disableWebViewSandboxEnv allows affected VDI users to opt out of the WebView2 sandbox.
|
||||
disableWebViewSandboxEnv = "CURSOR_BYOK_DISABLE_WEBVIEW_SANDBOX"
|
||||
)
|
||||
|
||||
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
|
||||
@@ -66,20 +70,21 @@ func Run(resources EmbeddedResources) error {
|
||||
logger.Init()
|
||||
netproxy.InstallDefaultTransport()
|
||||
|
||||
embeddedCACertPEM := certs.EmbeddedCACertPEM()
|
||||
logEmbeddedCAInfo(embeddedCACertPEM)
|
||||
|
||||
certManager, err := certs.NewEmbeddedManager()
|
||||
if err := appdata.EnsureAssistantHome(); err != nil {
|
||||
return err
|
||||
}
|
||||
certManager, caCertPEM, err := certs.LoadOrCreateManager(appdata.CACertFilePath(), appdata.CAKeyFilePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logCAInfo(caCertPEM)
|
||||
|
||||
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
|
||||
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proxyService := bridge.NewProxyService(proxyServer, certManager, embeddedCACertPEM)
|
||||
proxyService := bridge.NewProxyService(proxyServer, certManager, caCertPEM)
|
||||
adAssetBaseURL := defaultBackendBaseURL
|
||||
if cfg, err := proxyService.LoadUserConfig(); err == nil {
|
||||
adAssetBaseURL = browserReachableLoopbackBaseURL(cfg.BackendListenAddr)
|
||||
@@ -134,6 +139,9 @@ func Run(resources EmbeddedResources) error {
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(resources.Assets),
|
||||
},
|
||||
Windows: application.WindowsOptions{
|
||||
AdditionalBrowserArgs: windowsAdditionalBrowserArgs(),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ActivationPolicy: application.ActivationPolicyAccessory,
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||
@@ -208,16 +216,16 @@ func Run(resources EmbeddedResources) error {
|
||||
mainWindow = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: appName,
|
||||
Width: 700,
|
||||
Height: 520,
|
||||
MinWidth: 640,
|
||||
MinHeight: 480,
|
||||
Height: 530,
|
||||
MinWidth: 700,
|
||||
MinHeight: 530,
|
||||
DisableResize: false,
|
||||
Frameless: goruntime.GOOS == "windows",
|
||||
URL: "/",
|
||||
Hidden: false,
|
||||
HideOnEscape: false,
|
||||
MinimiseButtonState: application.ButtonEnabled,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonEnabled,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: application.RGBA{Red: 25, Green: 25, Blue: 25, Alpha: 255},
|
||||
Mac: application.MacWindow{
|
||||
@@ -268,37 +276,100 @@ func Run(resources EmbeddedResources) error {
|
||||
menu.AddSeparator()
|
||||
startItem := menu.Add("启动服务")
|
||||
stopItem := menu.Add("停止服务")
|
||||
menu.Add("检查更新").OnClick(func(ctx *application.Context) {
|
||||
updateItem := menu.Add("检查更新").OnClick(func(ctx *application.Context) {
|
||||
updateManager.CheckNow(true)
|
||||
})
|
||||
menu.AddSeparator()
|
||||
menu.Add("显示窗口").OnClick(func(ctx *application.Context) {
|
||||
showItem := menu.Add("显示窗口").OnClick(func(ctx *application.Context) {
|
||||
showMainWindow()
|
||||
})
|
||||
menu.Add("隐藏窗口").OnClick(func(ctx *application.Context) {
|
||||
hideItem := menu.Add("隐藏窗口").OnClick(func(ctx *application.Context) {
|
||||
window.Hide()
|
||||
})
|
||||
menu.AddSeparator()
|
||||
menu.Add("退出").OnClick(func(ctx *application.Context) {
|
||||
quitItem := menu.Add("退出").OnClick(func(ctx *application.Context) {
|
||||
proxyService.ShutdownForQuit()
|
||||
app.Quit()
|
||||
})
|
||||
|
||||
var currentLocale = "zh-CN"
|
||||
|
||||
updateTrayLabels := func(locale string) {
|
||||
currentLocale = locale
|
||||
state := proxyService.GetState()
|
||||
if state.Running {
|
||||
if locale == "en-US" {
|
||||
statusItem.SetLabel("Status: Running")
|
||||
} else if locale == "ja-JP" {
|
||||
statusItem.SetLabel("状態:実行中")
|
||||
} else if locale == "ru-RU" {
|
||||
statusItem.SetLabel("Статус: запущено")
|
||||
} else {
|
||||
statusItem.SetLabel("状态:运行中")
|
||||
}
|
||||
} else {
|
||||
if locale == "en-US" {
|
||||
statusItem.SetLabel("Status: Not Started")
|
||||
} else if locale == "ja-JP" {
|
||||
statusItem.SetLabel("状態:未起動")
|
||||
} else if locale == "ru-RU" {
|
||||
statusItem.SetLabel("Статус: не запущено")
|
||||
} else {
|
||||
statusItem.SetLabel("状态:未启动")
|
||||
}
|
||||
}
|
||||
|
||||
if locale == "en-US" {
|
||||
startItem.SetLabel("Start Service")
|
||||
stopItem.SetLabel("Stop Service")
|
||||
updateItem.SetLabel("Check for Updates")
|
||||
showItem.SetLabel("Show Window")
|
||||
hideItem.SetLabel("Hide Window")
|
||||
quitItem.SetLabel("Exit")
|
||||
} else if locale == "ja-JP" {
|
||||
startItem.SetLabel("サービス起動")
|
||||
stopItem.SetLabel("サービス停止")
|
||||
updateItem.SetLabel("アップデートを確認")
|
||||
showItem.SetLabel("ウィンドウを表示")
|
||||
hideItem.SetLabel("ウィンドウを非表示")
|
||||
quitItem.SetLabel("終了")
|
||||
} else if locale == "ru-RU" {
|
||||
startItem.SetLabel("Запустить сервис")
|
||||
stopItem.SetLabel("Остановить сервис")
|
||||
updateItem.SetLabel("Проверить обновления")
|
||||
showItem.SetLabel("Показать окно")
|
||||
hideItem.SetLabel("Скрыть окно")
|
||||
quitItem.SetLabel("Выход")
|
||||
} else {
|
||||
startItem.SetLabel("启动服务")
|
||||
stopItem.SetLabel("停止服务")
|
||||
updateItem.SetLabel("检查更新")
|
||||
showItem.SetLabel("显示窗口")
|
||||
hideItem.SetLabel("隐藏窗口")
|
||||
quitItem.SetLabel("退出")
|
||||
}
|
||||
}
|
||||
|
||||
refreshTray := func() {
|
||||
state := proxyService.GetState()
|
||||
if state.Running {
|
||||
statusItem.SetLabel("状态:运行中")
|
||||
startItem.SetEnabled(false)
|
||||
stopItem.SetEnabled(true)
|
||||
} else {
|
||||
statusItem.SetLabel("状态:未启动")
|
||||
startItem.SetEnabled(true)
|
||||
stopItem.SetEnabled(false)
|
||||
}
|
||||
updateTrayLabels(currentLocale)
|
||||
if refreshAdAssetBaseURL() {
|
||||
refreshAdRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
app.Event.On("locale:changed", func(e *application.CustomEvent) {
|
||||
if locale, ok := e.Data.(string); ok {
|
||||
updateTrayLabels(locale)
|
||||
}
|
||||
})
|
||||
app.Event.On("proxy:state", func(event *application.CustomEvent) {
|
||||
refreshTray()
|
||||
})
|
||||
@@ -352,6 +423,14 @@ func Run(resources EmbeddedResources) error {
|
||||
return app.Run()
|
||||
}
|
||||
|
||||
func windowsAdditionalBrowserArgs() []string {
|
||||
disableSandbox, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(disableWebViewSandboxEnv)))
|
||||
if err != nil || !disableSandbox {
|
||||
return nil
|
||||
}
|
||||
return []string{"--no-sandbox"}
|
||||
}
|
||||
|
||||
func browserReachableLoopbackBaseURL(listenAddr string) string {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(listenAddr))
|
||||
if err != nil || strings.TrimSpace(port) == "" {
|
||||
@@ -364,20 +443,20 @@ func browserReachableLoopbackBaseURL(listenAddr string) string {
|
||||
return "http://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
|
||||
func logEmbeddedCAInfo(certPEM []byte) {
|
||||
// logCAInfo 记录当前安装专属 CA 的公开信息。
|
||||
func logCAInfo(certPEM []byte) {
|
||||
if len(certPEM) == 0 {
|
||||
logger.Errorf("embedded CA is empty")
|
||||
logger.Errorf("installation CA is empty")
|
||||
return
|
||||
}
|
||||
cert, err := parseEmbeddedCert(certPEM)
|
||||
cert, err := parseCert(certPEM)
|
||||
if err != nil {
|
||||
logger.Errorf("parse embedded CA failed: %v", err)
|
||||
logger.Errorf("parse installation CA failed: %v", err)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
logger.Infof(
|
||||
"embedded CA loaded: sha256=%s subject=%s valid=%s~%s",
|
||||
"installation CA loaded: sha256=%s subject=%s valid=%s~%s",
|
||||
strings.ToUpper(hex.EncodeToString(sum[:])),
|
||||
cert.Subject.String(),
|
||||
cert.NotBefore.Format(time.RFC3339),
|
||||
@@ -385,8 +464,8 @@ func logEmbeddedCAInfo(certPEM []byte) {
|
||||
)
|
||||
}
|
||||
|
||||
// parseEmbeddedCert 用于处理与 parseEmbeddedCert 相关的逻辑。
|
||||
func parseEmbeddedCert(data []byte) (*x509.Certificate, error) {
|
||||
// parseCert 解析 DER 或 PEM 编码的证书。
|
||||
func parseCert(data []byte) (*x509.Certificate, error) {
|
||||
if block, _ := pem.Decode(data); block != nil {
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWindowsAdditionalBrowserArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset"},
|
||||
{name: "enabled with one", env: "1", want: []string{"--no-sandbox"}},
|
||||
{name: "enabled with true", env: " true ", want: []string{"--no-sandbox"}},
|
||||
{name: "disabled", env: "false"},
|
||||
{name: "invalid", env: "yes"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(disableWebViewSandboxEnv, tt.env)
|
||||
if got := windowsAdditionalBrowserArgs(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("windowsAdditionalBrowserArgs() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,3 +70,8 @@ func LogsRootPath() string {
|
||||
func CACertFilePath() string {
|
||||
return filepath.Join(DataRootPath(), "ca.crt")
|
||||
}
|
||||
|
||||
// CAKeyFilePath 返回仅供本安装使用的 CA 私钥路径。
|
||||
func CAKeyFilePath() string {
|
||||
return filepath.Join(DataRootPath(), "ca.key")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend 架构说明
|
||||
|
||||
`internal/backend` 当前支持本地助手模式与直连上游模式。
|
||||
`internal/backend` 只支持本地助手模式。
|
||||
|
||||
关于 backend agent「最小事实集合」的第一阶段研究文档,见 [`../../docs/backend-agent-minimum-facts-phase1.md`](../../docs/backend-agent-minimum-facts-phase1.md)。
|
||||
|
||||
@@ -34,7 +34,6 @@ internal/backend/
|
||||
errors.go
|
||||
local.go
|
||||
middleware.go
|
||||
policy.go
|
||||
route.go
|
||||
url.go
|
||||
|
||||
@@ -98,6 +97,7 @@ internal/backend/
|
||||
|
||||
- `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.crt`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.key`
|
||||
- `~/.cursor-local-assistant-v2/data/ads/`
|
||||
- `~/.cursor-local-assistant-v2/history/`
|
||||
- `~/.cursor-local-assistant-v2/logs/`
|
||||
@@ -105,7 +105,8 @@ internal/backend/
|
||||
约定:
|
||||
|
||||
- `config.yaml` 是用户配置
|
||||
- `data/ca.crt` 是注入给宿主的 CA 证书
|
||||
- `data/ca.crt` 是首次运行时为当前用户生成、注入给宿主的 CA 证书
|
||||
- `data/ca.key` 是与该证书配套的本地私钥,权限固定为 `0600`,不得打包或提交到仓库
|
||||
- `data/ads/` 是广告包与资源缓存目录
|
||||
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志
|
||||
- `logs/` 只保留必要文本运行日志
|
||||
@@ -134,7 +135,7 @@ history/
|
||||
## 请求流
|
||||
|
||||
1. 请求进入 backend 根路由。
|
||||
2. `PolicyMiddleware` 根据 `routing.mode` 与 `X-Server-Upstream-URL` 选择本地或上游分支。
|
||||
2. `ServerContext` 解析 MITM 带入的原始目标地址,路由始终执行本地 action。
|
||||
3. `BidiAppend` / `RunSSE` 进入 `forwarder`。
|
||||
4. `forwarder` 先把当前 loop 状态写入 `state.json`,再把已发生语义事件追加到 `context.json`。
|
||||
5. 发给 LLM 的 prompt 只由 `context.json` 投射生成;`state.json` 不保存可投射历史。
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
package execbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -31,10 +38,18 @@ type ExecApplyResult struct {
|
||||
ToolResultPayload string
|
||||
// ToolCall 保存可用于发 ToolCallCompletedUpdate 的工具调用对象;当前仅对支持 ToolCall 的执行型工具可用。
|
||||
ToolCall *agentv1.ToolCall
|
||||
// ContentBlobs 保存需要在提交 history 前写入内容寻址存储的二进制内容。
|
||||
ContentBlobs []ContentBlob
|
||||
// ExecuteHookResponse 保存 execute hook 的结构化响应。
|
||||
ExecuteHookResponse *agentv1.ExecuteHookResponse
|
||||
}
|
||||
|
||||
// ContentBlob 表示由内容哈希稳定寻址的执行结果二进制数据。
|
||||
type ContentBlob struct {
|
||||
ID []byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// OpenExecContext 表示执行桥打开请求时需要的最小上下文。
|
||||
type OpenExecContext struct {
|
||||
ConversationID string
|
||||
@@ -145,6 +160,9 @@ func (bridge *Bridge) ApplyExecClientMessage(msg *agentv1.ExecClientMessage, pen
|
||||
readResult := normalizeReadResultForModel(msg.GetReadResult())
|
||||
result.ToolResultPayload = summarizeReadResult(readResult)
|
||||
result.ToolCall = buildReadCompletedToolCall(pending.ToolCallID, pending.ArgsJSON, readResult)
|
||||
if contentBlob, ok := readImageContentBlob(readResult); ok {
|
||||
result.ContentBlobs = []ContentBlob{contentBlob}
|
||||
}
|
||||
result.IsTerminal = true
|
||||
return result, nil
|
||||
case "write":
|
||||
@@ -2285,6 +2303,64 @@ func buildReadMcpResourceCompletedToolCall(argsJSON []byte, result *agentv1.Read
|
||||
}
|
||||
}
|
||||
|
||||
func supportedReadImageMIMEType(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
|
||||
configuration, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || configuration.Width <= 0 || configuration.Height <= 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "png":
|
||||
if detected == "image/png" {
|
||||
return detected
|
||||
}
|
||||
case "jpeg":
|
||||
if detected == "image/jpeg" {
|
||||
return detected
|
||||
}
|
||||
case "gif":
|
||||
if detected == "image/gif" {
|
||||
return detected
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readImageContentBlob(result *agentv1.ReadResult) (ContentBlob, bool) {
|
||||
success := result.GetSuccess()
|
||||
if success == nil {
|
||||
return ContentBlob{}, false
|
||||
}
|
||||
data := success.GetData()
|
||||
if supportedReadImageMIMEType(data) == "" {
|
||||
return ContentBlob{}, false
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return ContentBlob{
|
||||
ID: append([]byte(nil), digest[:]...),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, true
|
||||
}
|
||||
|
||||
func readImageBlobID(data []byte) ([]byte, bool) {
|
||||
if supportedReadImageMIMEType(data) == "" {
|
||||
return nil, false
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
return append([]byte(nil), digest[:]...), true
|
||||
}
|
||||
|
||||
func readImageDataBlobOutput(data []byte) *agentv1.ReadToolSuccess_DataBlobId {
|
||||
blobID, ok := readImageBlobID(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &agentv1.ReadToolSuccess_DataBlobId{DataBlobId: blobID}
|
||||
}
|
||||
|
||||
// convertReadResultToReadToolResult 把 `ReadResult` 映射为 `ReadToolResult`。
|
||||
func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.ReadToolResult {
|
||||
if result == nil {
|
||||
@@ -2318,7 +2394,9 @@ func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.Read
|
||||
if content != "" {
|
||||
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{Content: content}
|
||||
} else if len(data) > 0 {
|
||||
if len(data) > readReplayBinaryLimit {
|
||||
if imageOutput := readImageDataBlobOutput(data); imageOutput != nil {
|
||||
toolSuccess.Output = imageOutput
|
||||
} else if len(data) > readReplayBinaryLimit {
|
||||
toolSuccess.ExceededLimit = true
|
||||
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{
|
||||
Content: replayTruncationNotice("Read binary data", readReplayBinaryLimit, 0, len(data)),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package execbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
func TestApplyExecClientMessageReturnsContentAddressedReadImage(t *testing.T) {
|
||||
imageData := validReadTestPNG(t)
|
||||
wantBlobID := sha256.Sum256(imageData)
|
||||
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
|
||||
Message: &agentv1.ExecClientMessage_ReadResult{
|
||||
ReadResult: &agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "diagram.png",
|
||||
FileSize: int64(len(imageData)),
|
||||
OutputBlobId: append([]byte(nil), wantBlobID[:]...),
|
||||
Output: &agentv1.ReadSuccess_Data{Data: imageData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, runtimecore.PendingExec{
|
||||
ExecKind: "read",
|
||||
ToolCallID: "call-1",
|
||||
ArgsJSON: []byte(`{"path":"diagram.png"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExecClientMessage() error = %v", err)
|
||||
}
|
||||
if len(result.ContentBlobs) != 1 {
|
||||
t.Fatalf("content blob count = %d, want 1", len(result.ContentBlobs))
|
||||
}
|
||||
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) || !bytes.Equal(result.ContentBlobs[0].Data, imageData) {
|
||||
t.Fatalf("content blob = %#v", result.ContentBlobs[0])
|
||||
}
|
||||
readSuccess := result.ToolCall.GetReadToolCall().GetResult().GetSuccess()
|
||||
if readSuccess == nil {
|
||||
t.Fatal("read tool result is not successful")
|
||||
}
|
||||
if !bytes.Equal(readSuccess.GetDataBlobId(), wantBlobID[:]) {
|
||||
t.Fatalf("data_blob_id = %x, want %x", readSuccess.GetDataBlobId(), wantBlobID)
|
||||
}
|
||||
if len(readSuccess.GetData()) != 0 {
|
||||
t.Fatalf("read tool result retained %d image bytes", len(readSuccess.GetData()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecClientMessageUsesComputedImageBlobID(t *testing.T) {
|
||||
imageData := validReadTestPNG(t)
|
||||
wantBlobID := sha256.Sum256(imageData)
|
||||
result, err := NewBridge().ApplyExecClientMessage(&agentv1.ExecClientMessage{
|
||||
Message: &agentv1.ExecClientMessage_ReadResult{
|
||||
ReadResult: &agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "diagram.png",
|
||||
OutputBlobId: bytes.Repeat([]byte{0xff}, sha256.Size),
|
||||
Output: &agentv1.ReadSuccess_Data{Data: imageData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, runtimecore.PendingExec{ExecKind: "read", ToolCallID: "call-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyExecClientMessage() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(result.ContentBlobs[0].ID, wantBlobID[:]) {
|
||||
t.Fatalf("content blob id = %x, want computed %x", result.ContentBlobs[0].ID, wantBlobID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertReadResultKeepsTextAndLimitsUnsupportedBinary(t *testing.T) {
|
||||
textResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "notes.txt",
|
||||
Output: &agentv1.ReadSuccess_Content{Content: "hello"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if got := textResult.GetSuccess().GetContent(); got != "hello" {
|
||||
t.Fatalf("text read content = %q, want hello", got)
|
||||
}
|
||||
|
||||
largeBinary := bytes.Repeat([]byte{0xff}, readReplayBinaryLimit+1)
|
||||
binaryResult := convertReadResultToReadToolResult(&agentv1.ReadResult{
|
||||
Result: &agentv1.ReadResult_Success{
|
||||
Success: &agentv1.ReadSuccess{
|
||||
Path: "archive.bin",
|
||||
Output: &agentv1.ReadSuccess_Data{Data: largeBinary},
|
||||
},
|
||||
},
|
||||
})
|
||||
binarySuccess := binaryResult.GetSuccess()
|
||||
if binarySuccess == nil || !binarySuccess.GetExceededLimit() {
|
||||
t.Fatal("large non-image binary was not limited")
|
||||
}
|
||||
if binarySuccess.GetData() != nil || binarySuccess.GetDataBlobId() != nil {
|
||||
t.Fatal("large non-image binary was retained")
|
||||
}
|
||||
if !strings.Contains(binarySuccess.GetContent(), "Read binary data") {
|
||||
t.Fatalf("large binary fallback = %q", binarySuccess.GetContent())
|
||||
}
|
||||
}
|
||||
|
||||
func validReadTestPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
value := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
value.Set(0, 0, color.RGBA{R: 0x44, G: 0x88, B: 0xcc, A: 0xff})
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, value); err != nil {
|
||||
t.Fatalf("encode test png: %v", err)
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
const (
|
||||
baiduWebSearchBaseURL = "https://www.baidu.com/s?ie=utf-8&tn=baidu&wd="
|
||||
baiduWebSearchHostURL = "https://www.baidu.com"
|
||||
baiduSearchAbstractLimit = 300
|
||||
baiduSearchReferenceLimit = 8
|
||||
)
|
||||
|
||||
// extractBaiduWebSearchReferences 从百度搜索结果页 HTML 中解析出搜索结果列表。
|
||||
func extractBaiduWebSearchReferences(body string) []*agentv1.WebSearchReference {
|
||||
document, err := goquery.NewDocumentFromReader(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
references := make([]*agentv1.WebSearchReference, 0, baiduSearchReferenceLimit)
|
||||
document.Find("#content_left > div").EachWithBreak(func(_ int, selection *goquery.Selection) bool {
|
||||
if len(references) >= baiduSearchReferenceLimit {
|
||||
return false
|
||||
}
|
||||
if !selection.HasClass("c-container") {
|
||||
return true
|
||||
}
|
||||
title, resultURL, abstract := extractBaiduSearchResult(selection)
|
||||
if title == "" || resultURL == "" {
|
||||
return true
|
||||
}
|
||||
references = append(references, &agentv1.WebSearchReference{
|
||||
Title: title,
|
||||
Url: normalizeBaiduSearchURL(resultURL),
|
||||
Chunk: truncateBaiduSearchAbstract(abstract),
|
||||
})
|
||||
return true
|
||||
})
|
||||
return references
|
||||
}
|
||||
|
||||
// extractBaiduSearchResult 从单条百度搜索结果节点中提取标题、链接和摘要。
|
||||
func extractBaiduSearchResult(selection *goquery.Selection) (string, string, string) {
|
||||
title := cleanBaiduSearchText(selection.Find("h3").First().Text())
|
||||
resultURL, _ := selection.Find("h3 a").First().Attr("href")
|
||||
if title == "" {
|
||||
title = firstBaiduSearchLine(selection.Text())
|
||||
}
|
||||
if resultURL == "" {
|
||||
resultURL, _ = selection.Find("a").First().Attr("href")
|
||||
}
|
||||
abstract := cleanBaiduSearchText(selection.Find(".c-abstract").First().Text())
|
||||
if abstract == "" {
|
||||
abstract = cleanBaiduSearchText(selection.ChildrenFiltered("div").First().Text())
|
||||
}
|
||||
if abstract == "" {
|
||||
abstract = baiduSearchTextAfterFirstLine(selection.Text())
|
||||
}
|
||||
return title, strings.TrimSpace(resultURL), abstract
|
||||
}
|
||||
|
||||
// normalizeBaiduSearchURL 把百度返回的相对或协议省略链接归一化为绝对 URL。
|
||||
func normalizeBaiduSearchURL(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if strings.HasPrefix(rawURL, "//") {
|
||||
return "https:" + rawURL
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/") {
|
||||
return baiduWebSearchHostURL + rawURL
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// resolveBaiduWebSearchRedirects 把百度跳转链接解析为最终目标地址,就地更新引用列表。
|
||||
func resolveBaiduWebSearchRedirects(client *http.Client, references []*agentv1.WebSearchReference) {
|
||||
for _, reference := range references {
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
reference.Url = resolveBaiduRedirectURL(client, reference.GetUrl())
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBaiduRedirectURL 判断链接是否是百度跳转链接,并尝试解析出真实目标。
|
||||
func resolveBaiduRedirectURL(client *http.Client, rawURL string) string {
|
||||
resultURL := normalizeBaiduSearchURL(rawURL)
|
||||
if !isBaiduRedirectURL(resultURL) {
|
||||
return resultURL
|
||||
}
|
||||
redirectClient := baiduRedirectHTTPClient(client)
|
||||
if location := requestBaiduRedirectLocation(redirectClient, http.MethodHead, resultURL); location != "" {
|
||||
return location
|
||||
}
|
||||
if location := requestBaiduRedirectLocation(redirectClient, http.MethodGet, resultURL); location != "" {
|
||||
return location
|
||||
}
|
||||
return resultURL
|
||||
}
|
||||
|
||||
// baiduRedirectHTTPClient 基于基础 client 构造一个不自动跟随重定向的短超时客户端。
|
||||
func baiduRedirectHTTPClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
base = http.DefaultClient
|
||||
}
|
||||
client := *base
|
||||
if client.Timeout == 0 || client.Timeout > 6*time.Second {
|
||||
client.Timeout = 6 * time.Second
|
||||
}
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &client
|
||||
}
|
||||
|
||||
// requestBaiduRedirectLocation 发起一次请求并读取响应头中的重定向目标地址。
|
||||
func requestBaiduRedirectLocation(client *http.Client, method string, rawURL string) string {
|
||||
request, err := http.NewRequest(method, rawURL, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36")
|
||||
request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
request.Header.Set("Referer", baiduWebSearchHostURL+"/")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer response.Body.Close()
|
||||
location := strings.TrimSpace(response.Header.Get("Location"))
|
||||
if location == "" {
|
||||
return ""
|
||||
}
|
||||
return resolveBaiduLocationURL(rawURL, location)
|
||||
}
|
||||
|
||||
// resolveBaiduLocationURL 把响应头里的相对重定向地址解析为绝对地址。
|
||||
func resolveBaiduLocationURL(baseURL string, location string) string {
|
||||
parsedLocation, err := neturl.Parse(location)
|
||||
if err != nil {
|
||||
return location
|
||||
}
|
||||
if parsedLocation.IsAbs() {
|
||||
return parsedLocation.String()
|
||||
}
|
||||
parsedBase, err := neturl.Parse(baseURL)
|
||||
if err != nil {
|
||||
return location
|
||||
}
|
||||
return parsedBase.ResolveReference(parsedLocation).String()
|
||||
}
|
||||
|
||||
// isBaiduRedirectURL 判断给定地址是否是百度域名下的跳转链接。
|
||||
func isBaiduRedirectURL(rawURL string) bool {
|
||||
parsedURL, err := neturl.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsedURL.Hostname())
|
||||
path := strings.ToLower(parsedURL.EscapedPath())
|
||||
return (host == "baidu.com" || strings.HasSuffix(host, ".baidu.com")) && strings.HasPrefix(path, "/link")
|
||||
}
|
||||
|
||||
// truncateBaiduSearchAbstract 按字符数截断摘要文本,避免结果过长。
|
||||
func truncateBaiduSearchAbstract(value string) string {
|
||||
value = cleanBaiduSearchText(value)
|
||||
if baiduSearchAbstractLimit <= 0 {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= baiduSearchAbstractLimit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:baiduSearchAbstractLimit])
|
||||
}
|
||||
|
||||
// firstBaiduSearchLine 返回文本中第一个非空行。
|
||||
func firstBaiduSearchLine(value string) string {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(value, "\r", "\n"), "\n") {
|
||||
line = cleanBaiduSearchText(line)
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// baiduSearchTextAfterFirstLine 返回除第一个非空行外剩余文本的拼接结果。
|
||||
func baiduSearchTextAfterFirstLine(value string) string {
|
||||
nonEmpty := make([]string, 0, 8)
|
||||
for _, line := range strings.Split(strings.ReplaceAll(value, "\r", "\n"), "\n") {
|
||||
line = cleanBaiduSearchText(line)
|
||||
if line != "" {
|
||||
nonEmpty = append(nonEmpty, line)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) <= 1 {
|
||||
return ""
|
||||
}
|
||||
return cleanBaiduSearchText(strings.Join(nonEmpty[1:], " "))
|
||||
}
|
||||
|
||||
// cleanBaiduSearchText 折叠多余空白并去除首尾空格。
|
||||
func cleanBaiduSearchText(value string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||||
}
|
||||
@@ -577,13 +577,68 @@ const (
|
||||
)
|
||||
|
||||
func (bridge *Bridge) executeWebSearch(searchTerm string) ([]*agentv1.WebSearchReference, string, error) {
|
||||
if strings.TrimSpace(searchTerm) == "" {
|
||||
searchTerm = strings.TrimSpace(searchTerm)
|
||||
if searchTerm == "" {
|
||||
return nil, "", fmt.Errorf("web search search_term is required")
|
||||
}
|
||||
client := bridge.httpClient
|
||||
if client == nil {
|
||||
client = netproxy.NewHTTPClient(15 * time.Second)
|
||||
}
|
||||
|
||||
// 先尝试百度搜索
|
||||
baiduReferences, baiduPayload, baiduErr := bridge.tryBaiduWebSearch(client, searchTerm)
|
||||
if baiduErr == nil && len(baiduReferences) > 0 {
|
||||
return baiduReferences, baiduPayload, nil
|
||||
}
|
||||
|
||||
// 百度失败,回退到 DuckDuckGo
|
||||
duckReferences, duckPayload, duckErr := bridge.tryDuckDuckGoWebSearch(client, searchTerm)
|
||||
if duckErr == nil && len(duckReferences) > 0 {
|
||||
return duckReferences, duckPayload, nil
|
||||
}
|
||||
|
||||
// 两者都失败,返回综合错误
|
||||
if baiduErr != nil && duckErr != nil {
|
||||
return nil, "", fmt.Errorf("web search failed: baidu=%v, duckduckgo=%v", baiduErr, duckErr)
|
||||
}
|
||||
return nil, "", fmt.Errorf("web search returned no parseable results")
|
||||
}
|
||||
|
||||
func (bridge *Bridge) tryBaiduWebSearch(client *http.Client, searchTerm string) ([]*agentv1.WebSearchReference, string, error) {
|
||||
requestURL := baiduWebSearchBaseURL + neturl.QueryEscape(searchTerm)
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36")
|
||||
request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8")
|
||||
request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
request.Header.Set("Referer", baiduWebSearchHostURL+"/")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("baidu http status %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
references := extractBaiduWebSearchReferences(string(body))
|
||||
if len(references) == 0 {
|
||||
return nil, "", fmt.Errorf("baidu returned no parseable results")
|
||||
}
|
||||
if len(references) > 5 {
|
||||
references = references[:5]
|
||||
}
|
||||
resolveBaiduWebSearchRedirects(client, references)
|
||||
return references, formatWebSearchPayload(searchTerm, references), nil
|
||||
}
|
||||
|
||||
func (bridge *Bridge) tryDuckDuckGoWebSearch(client *http.Client, searchTerm string) ([]*agentv1.WebSearchReference, string, error) {
|
||||
requestURL := webSearchURLOverride + neturl.QueryEscape(searchTerm)
|
||||
request, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -156,6 +156,18 @@ func anthropicEndpointURL(baseURL string) string {
|
||||
return base + "/v1/messages"
|
||||
}
|
||||
|
||||
// shouldRelocateAnthropicImages 判断是否需要把图片块搬运到末条 user 消息。
|
||||
//
|
||||
// 官方 Anthropic 端点(api.anthropic.com)可正确处理任意位置的图片,保持原样;
|
||||
// 其余第三方中转站默认启用搬运,规避「非末尾图片被丢弃」的转换问题。
|
||||
func shouldRelocateAnthropicImages(baseURL string) bool {
|
||||
base := strings.ToLower(strings.TrimSpace(baseURL))
|
||||
if base == "" {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(base, "api.anthropic.com")
|
||||
}
|
||||
|
||||
// ApplyAnthropicCompatibleAuthHeaders 同时兼容 Anthropic 原生 x-api-key 和 Bearer token 代理。
|
||||
func ApplyAnthropicCompatibleAuthHeaders(httpReq *http.Request, apiKey string) {
|
||||
if httpReq == nil {
|
||||
@@ -215,8 +227,9 @@ func (adapter *AnthropicAdapter) Stream(ctx context.Context, req StreamRequest,
|
||||
body := cloneRequestBodyOverride(req.RequestBodyOverride)
|
||||
if len(body) == 0 {
|
||||
thinkingConfig := buildAnthropicThinkingConfig(req)
|
||||
relocateImages := shouldRelocateAnthropicImages(baseURL)
|
||||
stableMessageCount := anthropicStableProviderMessageCount(req.Messages, req.StableMessageCount, thinkingConfig != nil)
|
||||
systemParts, messages, err := normalizeAnthropicProviderMessages(req.Messages, thinkingConfig != nil)
|
||||
systemParts, messages, err := normalizeAnthropicProviderMessages(req.Messages, thinkingConfig != nil, relocateImages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -261,18 +274,17 @@ func (adapter *AnthropicAdapter) Stream(ctx context.Context, req StreamRequest,
|
||||
body["tools"] = tools
|
||||
}
|
||||
body["system"] = anthropicProviderSystemBlocks(systemParts)
|
||||
if thinkingConfig != nil {
|
||||
body["thinking"] = thinkingConfig
|
||||
if normalizeRuntimeThinkingEffort(req.ThinkingEffort) != "disabled" {
|
||||
body["output_config"] = buildAnthropicOutputConfig(req)
|
||||
}
|
||||
}
|
||||
frontier := buildAnthropicCacheFrontier(body, stableMessageCount)
|
||||
req.RequestKnobs = annotateAnthropicRequestKnobs(req.RequestKnobs, body, frontier)
|
||||
body = cloneRequestBodyOverride(body)
|
||||
applyAnthropicCacheBreakpoints(body, frontier.BreakpointPositions)
|
||||
frontier.BreakpointCount = len(frontier.BreakpointPositions)
|
||||
}
|
||||
// applyAnthropicThinkingConfig 在 override 块之外无条件调用,确保 RequestBodyOverride
|
||||
// 路径与正常构造路径行为一致:disabled 时强制 thinking:{type:disabled} 并清理冲突字段,
|
||||
// 非 disabled 时按 AnthropicThinkingEffort 写 adaptive 配置。与 openai.go 的
|
||||
// applyOpenAIThinkingDisable 对称——后者也是无条件在两条路径之后调用。
|
||||
applyAnthropicThinkingConfig(body, req)
|
||||
if err := ApplyAnthropicExtraParams(body, req.AnthropicExtraParamsEnabled, req.AnthropicExtraParamsJSON); err != nil {
|
||||
finishedAt = time.Now().UTC()
|
||||
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
|
||||
@@ -1066,7 +1078,7 @@ func anthropicStableProviderMessageCount(input []Message, stableReplayMessageCou
|
||||
if len(stableReplayMessages) == 0 {
|
||||
return 0
|
||||
}
|
||||
_, messages, err := normalizeAnthropicProviderMessages(stableReplayMessages, thinkingEnabled)
|
||||
_, messages, err := normalizeAnthropicProviderMessages(stableReplayMessages, thinkingEnabled, false)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
@@ -1114,7 +1126,16 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
|
||||
case contentPartTypeText:
|
||||
return strings.TrimSpace(anthropicStringField(block, "text")) != ""
|
||||
case "tool_result":
|
||||
return strings.TrimSpace(anthropicStringField(block, "content")) != ""
|
||||
switch content := block["content"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(content) != ""
|
||||
case []map[string]any:
|
||||
return len(content) > 0
|
||||
case []any:
|
||||
return len(content) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case "tool_use":
|
||||
return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != ""
|
||||
default:
|
||||
@@ -1122,10 +1143,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool) ([]string, []anthropicMessage, error) {
|
||||
// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。
|
||||
// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek
|
||||
// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的
|
||||
// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。
|
||||
type anthropicThinkingCarrier struct {
|
||||
reasoning string
|
||||
signature string
|
||||
}
|
||||
|
||||
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
|
||||
systemParts := make([]string, 0, len(input))
|
||||
messages := make([]anthropicMessage, 0, len(input))
|
||||
pendingToolResults := make([]map[string]any, 0, 2)
|
||||
var thinkingCarrier *anthropicThinkingCarrier
|
||||
flushToolResults := func() {
|
||||
if len(pendingToolResults) == 0 {
|
||||
return
|
||||
@@ -1156,14 +1187,30 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool) (
|
||||
if toolUseID == "" {
|
||||
return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id")
|
||||
}
|
||||
var content any = message.Content
|
||||
if hasImageContentParts(message.ContentParts) {
|
||||
contentBlocks, err := anthropicContentBlocks(message)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
content = contentBlocks
|
||||
}
|
||||
pendingToolResults = append(pendingToolResults, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": toolUseID,
|
||||
"content": message.Content,
|
||||
"content": content,
|
||||
})
|
||||
case "user", "assistant":
|
||||
flushToolResults()
|
||||
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled)
|
||||
if thinkingEnabled && role == "assistant" {
|
||||
if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" {
|
||||
thinkingCarrier = &anthropicThinkingCarrier{
|
||||
reasoning: reasoning,
|
||||
signature: anthropicThinkingSignature(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -1208,10 +1255,71 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool) (
|
||||
}
|
||||
}
|
||||
flushToolResults()
|
||||
if relocateImages {
|
||||
messages = relocateAnthropicImagesToLastUserMessage(messages)
|
||||
}
|
||||
return systemParts, messages, nil
|
||||
}
|
||||
|
||||
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) {
|
||||
// relocateAnthropicImagesToLastUserMessage 把所有 user 消息里的 image 块搬运到最后一条 user 消息的末尾。
|
||||
//
|
||||
// 背景:部分第三方中转站(如 Bedrock 代理)在 Anthropic→上游 的消息转换中,
|
||||
// 会丢弃「后面还跟着大量文本/消息」的非末尾图片块。将图片统一移动到末条 user 消息
|
||||
// 可规避该问题,同时保留图片信息本身。
|
||||
//
|
||||
// 数据流演变:
|
||||
//
|
||||
// [user_info] [query + IMG] [reminder] [reminder] [current_request]
|
||||
// → [user_info] [query] [reminder] [reminder] [current_request + IMG]
|
||||
//
|
||||
// 搬运后若某条 user 消息 content 变空,则丢弃该消息,避免 Anthropic 拒绝空内容消息。
|
||||
func relocateAnthropicImagesToLastUserMessage(messages []anthropicMessage) []anthropicMessage {
|
||||
lastUserIndex := -1
|
||||
for index := len(messages) - 1; index >= 0; index-- {
|
||||
if strings.TrimSpace(messages[index].Role) == "user" {
|
||||
lastUserIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastUserIndex < 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
relocated := make([]map[string]any, 0, 2)
|
||||
for index := 0; index < len(messages); index++ {
|
||||
if index == lastUserIndex || strings.TrimSpace(messages[index].Role) != "user" {
|
||||
continue
|
||||
}
|
||||
kept := make([]map[string]any, 0, len(messages[index].Content))
|
||||
for _, block := range messages[index].Content {
|
||||
if isAnthropicImageBlock(block) {
|
||||
relocated = append(relocated, block)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, block)
|
||||
}
|
||||
messages[index].Content = kept
|
||||
}
|
||||
if len(relocated) == 0 {
|
||||
return messages
|
||||
}
|
||||
messages[lastUserIndex].Content = append(messages[lastUserIndex].Content, relocated...)
|
||||
|
||||
compacted := make([]anthropicMessage, 0, len(messages))
|
||||
for index, message := range messages {
|
||||
if index != lastUserIndex && strings.TrimSpace(message.Role) == "user" && len(message.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
compacted = append(compacted, message)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
func isAnthropicImageBlock(block map[string]any) bool {
|
||||
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
|
||||
}
|
||||
|
||||
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
|
||||
blocks, err := anthropicContentBlocks(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1220,11 +1328,17 @@ func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]ma
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
reasoning := strings.TrimSpace(message.ReasoningContent)
|
||||
signature := anthropicThinkingSignature(message)
|
||||
if reasoning == "" && carrier != nil {
|
||||
reasoning = carrier.reasoning
|
||||
signature = carrier.signature
|
||||
}
|
||||
thinkingBlock := map[string]any{
|
||||
"type": "thinking",
|
||||
"thinking": message.ReasoningContent,
|
||||
"thinking": reasoning,
|
||||
}
|
||||
if signature := anthropicThinkingSignature(message); signature != "" {
|
||||
if signature != "" {
|
||||
thinkingBlock["signature"] = signature
|
||||
}
|
||||
return append([]map[string]any{thinkingBlock}, blocks...), nil
|
||||
@@ -1308,9 +1422,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool)
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningContent) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1367,6 +1478,34 @@ func buildAnthropicThinkingConfig(req StreamRequest) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// applyAnthropicThinkingConfig 在请求体构造完成后(含 RequestBodyOverride 路径)无条件调用,
|
||||
// 与 openai.go 的 applyOpenAIThinkingDisable 对称。它把 thinking 配置写入 body 并在 disabled
|
||||
// 时清理与之冲突的字段,确保两条构造路径行为一致:
|
||||
// - disabled: 强制 thinking:{type:"disabled"},删除 output_config / 残留 thinking adaptive 配置,
|
||||
// 记录 thinking_disabled_provider_param=thinking.type knob
|
||||
// - adaptive: 按 AnthropicThinkingEffort 写 thinking:{type:adaptive,display:summarized} + output_config
|
||||
//
|
||||
// 在 override 路径下,上层若已在 override body 里塞了 thinking/output_config,disabled 时会被正确覆盖。
|
||||
func applyAnthropicThinkingConfig(body map[string]any, req StreamRequest) {
|
||||
if len(body) == 0 {
|
||||
return
|
||||
}
|
||||
if normalizeRuntimeThinkingEffort(req.ThinkingEffort) != "disabled" {
|
||||
if strings.TrimSpace(req.AnthropicThinkingEffort) == "" {
|
||||
return
|
||||
}
|
||||
body["thinking"] = map[string]any{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
}
|
||||
body["output_config"] = buildAnthropicOutputConfig(req)
|
||||
return
|
||||
}
|
||||
body["thinking"] = map[string]any{"type": "disabled"}
|
||||
delete(body, "output_config")
|
||||
setRequestKnob(req, "thinking_disabled_provider_param", "thinking.type")
|
||||
}
|
||||
|
||||
func buildAnthropicOutputConfig(req StreamRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"effort": anthropicThinkingEffort(req),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNormalizeAnthropicProviderMessagesThinkingCarrier 验证 thinking 模式下,
|
||||
// 缺少 reasoning 的 assistant 轮次(如 DeepSeek adaptive thinking 跳过思考的
|
||||
// tool-call 轮次)会用请求内最近一个 carrier 的 thinking+signature 兜底,
|
||||
// 保证每个 assistant 轮次都有 thinking 块,避免上游 "thinking must be passed
|
||||
// back to the API" 400。
|
||||
func TestNormalizeAnthropicProviderMessagesThinkingCarrier(t *testing.T) {
|
||||
carrierToolCall := []ToolCallDescriptor{{
|
||||
ID: "call-2",
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: "read",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
}}
|
||||
input := []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||
{Role: "user", Content: "tool result 1"},
|
||||
{Role: "assistant", ToolCalls: carrierToolCall}, // 无 reasoning → 用 carrier
|
||||
{Role: "user", Content: "tool result 2"},
|
||||
{Role: "assistant", Content: "done", ReasoningContent: "R2", ReasoningSignature: "S2"},
|
||||
}
|
||||
|
||||
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if len(messages) != 6 {
|
||||
t.Fatalf("expected 6 messages, got %d", len(messages))
|
||||
}
|
||||
|
||||
// 第 2 条(有 reasoning)应保留自己的 thinking。
|
||||
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
|
||||
// 第 4 条(无 reasoning 的 tool-call 轮次)应复用 carrier 的 thinking+signature。
|
||||
assertAnthropicThinkingBlock(t, messages[3], "R1", "S1")
|
||||
// 第 5 条应为 tool_result 消息(合并路径不适用时,tool-call 轮次独立成消息)。
|
||||
if role := messages[4].Role; role != "user" {
|
||||
t.Fatalf("expected messages[4] role=user, got %s", role)
|
||||
}
|
||||
// 第 6 条有自己的 thinking。
|
||||
assertAnthropicThinkingBlock(t, messages[5], "R2", "S2")
|
||||
|
||||
// tool-call 轮次应包含 tool_use 块。
|
||||
hasToolUse := false
|
||||
for _, block := range messages[3].Content {
|
||||
if strings.TrimSpace(anthropicStringField(block, "type")) == "tool_use" {
|
||||
hasToolUse = true
|
||||
}
|
||||
}
|
||||
if !hasToolUse {
|
||||
t.Fatal("expected tool_use block on the carrier-fallback assistant message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn 验证请求内第一条
|
||||
// assistant 轮次就缺 reasoning 且无 carrier 时,兜底输出空 thinking 块。
|
||||
func TestNormalizeAnthropicProviderMessagesThinkingCarrierFirstTurn(t *testing.T) {
|
||||
input := []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "ok"},
|
||||
}
|
||||
|
||||
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(messages))
|
||||
}
|
||||
if got := anthropicStringField(messages[1].Content[0], "type"); got != "thinking" {
|
||||
t.Fatalf("expected first block type=thinking, got %s", got)
|
||||
}
|
||||
if got := anthropicStringField(messages[1].Content[0], "thinking"); got != "" {
|
||||
t.Fatalf("expected empty fallback thinking, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAnthropicProviderMessagesThinkingDisabled 验证 thinking 关闭时
|
||||
// 不输出任何 thinking 块(回归保护)。
|
||||
func TestNormalizeAnthropicProviderMessagesThinkingDisabled(t *testing.T) {
|
||||
input := []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "ok", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
|
||||
ID: "call-2",
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: "read",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
}}},
|
||||
}
|
||||
|
||||
_, messages, err := normalizeAnthropicProviderMessages(input, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
for index, message := range messages {
|
||||
for _, block := range message.Content {
|
||||
if blockType := anthropicStringField(block, "type"); blockType == "thinking" {
|
||||
t.Fatalf("unexpected thinking block at messages[%d]", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAnthropicProviderMessagesThinkingMerge 验证有 reasoning 的纯
|
||||
// tool-call 轮次仍按既有逻辑合并进上一条 assistant 消息(thinking 去重,无回归)。
|
||||
func TestNormalizeAnthropicProviderMessagesThinkingMerge(t *testing.T) {
|
||||
input := []Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", Content: "let me check", ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||
{Role: "assistant", ToolCalls: []ToolCallDescriptor{{
|
||||
ID: "call-2",
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: "read",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
}}, ReasoningContent: "R1", ReasoningSignature: "S1"},
|
||||
}
|
||||
|
||||
_, messages, err := normalizeAnthropicProviderMessages(input, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("expected 2 messages (tool-call merged), got %d", len(messages))
|
||||
}
|
||||
assertAnthropicThinkingBlock(t, messages[1], "R1", "S1")
|
||||
hasToolUse := false
|
||||
for _, block := range messages[1].Content {
|
||||
if blockType := anthropicStringField(block, "type"); blockType == "tool_use" {
|
||||
hasToolUse = true
|
||||
}
|
||||
}
|
||||
if !hasToolUse {
|
||||
t.Fatal("expected merged tool_use block on messages[1]")
|
||||
}
|
||||
}
|
||||
|
||||
func assertAnthropicThinkingBlock(t *testing.T, message anthropicMessage, wantThinking string, wantSignature string) {
|
||||
t.Helper()
|
||||
if len(message.Content) == 0 {
|
||||
t.Fatalf("expected non-empty content for %s message", message.Role)
|
||||
}
|
||||
first := message.Content[0]
|
||||
if blockType := anthropicStringField(first, "type"); blockType != "thinking" {
|
||||
t.Fatalf("expected first block type=thinking, got %s", blockType)
|
||||
}
|
||||
if got := anthropicStringField(first, "thinking"); got != wantThinking {
|
||||
t.Fatalf("expected thinking=%q, got %q", wantThinking, got)
|
||||
}
|
||||
if got := anthropicStringField(first, "signature"); got != wantSignature {
|
||||
t.Fatalf("expected signature=%q, got %q", wantSignature, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestApplyAnthropicThinkingConfig covers the three effort branches on a body
|
||||
// built by the normal construction path, plus the override path symmetry that
|
||||
// was the original bug (thinking config was only applied inside the
|
||||
// `if len(body)==0` block, so RequestBodyOverride skipped it entirely).
|
||||
func TestApplyAnthropicThinkingConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
thinkingEffort string
|
||||
adaptiveEffort string
|
||||
wantThinking any
|
||||
wantOutputCfg bool // expect output_config key present
|
||||
}{
|
||||
{
|
||||
name: "disabled_writes_disabled_and_drops_output_config",
|
||||
body: map[string]any{"model": "m", "output_config": map[string]any{"effort": "high"}},
|
||||
thinkingEffort: "disabled",
|
||||
wantThinking: map[string]any{"type": "disabled"},
|
||||
wantOutputCfg: false,
|
||||
},
|
||||
{
|
||||
name: "adaptive_writes_adaptive_and_output_config",
|
||||
body: map[string]any{"model": "m"},
|
||||
adaptiveEffort: "high",
|
||||
wantThinking: map[string]any{"type": "adaptive", "display": "summarized"},
|
||||
wantOutputCfg: true,
|
||||
},
|
||||
{
|
||||
name: "empty_effort_no_op",
|
||||
body: map[string]any{"model": "m"},
|
||||
wantThinking: nil,
|
||||
wantOutputCfg: false,
|
||||
},
|
||||
{
|
||||
name: "disabled_overrides_existing_adaptive_thinking",
|
||||
body: map[string]any{"thinking": map[string]any{"type": "adaptive", "display": "summarized"}, "output_config": map[string]any{"effort": "high"}},
|
||||
thinkingEffort: "disabled",
|
||||
wantThinking: map[string]any{"type": "disabled"},
|
||||
wantOutputCfg: false,
|
||||
},
|
||||
{
|
||||
name: "disabled_normalizes_aliases",
|
||||
body: map[string]any{"model": "m"},
|
||||
thinkingEffort: "off",
|
||||
wantThinking: map[string]any{"type": "disabled"},
|
||||
wantOutputCfg: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := StreamRequest{
|
||||
ThinkingEffort: tc.thinkingEffort,
|
||||
AnthropicThinkingEffort: tc.adaptiveEffort,
|
||||
RequestKnobs: map[string]any{},
|
||||
}
|
||||
applyAnthropicThinkingConfig(tc.body, req)
|
||||
|
||||
gotThinking, hasThinking := tc.body["thinking"]
|
||||
if tc.wantThinking == nil {
|
||||
if hasThinking {
|
||||
t.Fatalf("expected no thinking key, got %v", gotThinking)
|
||||
}
|
||||
} else {
|
||||
if !hasThinking {
|
||||
t.Fatalf("expected thinking=%v, key absent", tc.wantThinking)
|
||||
}
|
||||
if !mapEqual(gotThinking, tc.wantThinking) {
|
||||
t.Fatalf("thinking mismatch\nwant: %v\ngot: %v", tc.wantThinking, gotThinking)
|
||||
}
|
||||
}
|
||||
|
||||
_, hasOutputCfg := tc.body["output_config"]
|
||||
if hasOutputCfg != tc.wantOutputCfg {
|
||||
t.Fatalf("output_config presence=%v, want %v", hasOutputCfg, tc.wantOutputCfg)
|
||||
}
|
||||
|
||||
if tc.thinkingEffort == "disabled" || tc.thinkingEffort == "off" {
|
||||
gotKnob := req.RequestKnobs["thinking_disabled_provider_param"]
|
||||
if gotKnob != "thinking.type" {
|
||||
t.Fatalf("knob thinking_disabled_provider_param=%v, want thinking.type", gotKnob)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAnthropicThinkingConfigOverridePath simulates the RequestBodyOverride
|
||||
// branch: the adapter receives a body that did NOT go through the normal
|
||||
// construction (no tools/messages/system written by the adapter). Before the
|
||||
// fix, thinking config was skipped entirely on this path. Now
|
||||
// applyAnthropicThinkingConfig runs unconditionally and disables thinking.
|
||||
func TestApplyAnthropicThinkingConfigOverridePath(t *testing.T) {
|
||||
overrideBody := map[string]any{
|
||||
"model": "claude-x",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
|
||||
"thinking": map[string]any{"type": "adaptive", "display": "summarized"},
|
||||
"output_config": map[string]any{"effort": "high"},
|
||||
"stream": true,
|
||||
}
|
||||
req := StreamRequest{
|
||||
ThinkingEffort: "disabled",
|
||||
RequestKnobs: map[string]any{},
|
||||
}
|
||||
|
||||
applyAnthropicThinkingConfig(overrideBody, req)
|
||||
|
||||
gotThinking := overrideBody["thinking"]
|
||||
if !mapEqual(gotThinking, map[string]any{"type": "disabled"}) {
|
||||
t.Fatalf("override path: thinking not forced to disabled, got %v", gotThinking)
|
||||
}
|
||||
if _, hasOutputCfg := overrideBody["output_config"]; hasOutputCfg {
|
||||
t.Fatalf("override path: output_config should be dropped on disabled, still present")
|
||||
}
|
||||
if gotKnob := req.RequestKnobs["thinking_disabled_provider_param"]; gotKnob != "thinking.type" {
|
||||
t.Fatalf("override path: knob=%v, want thinking.type", gotKnob)
|
||||
}
|
||||
}
|
||||
|
||||
func mapEqual(a, b any) bool {
|
||||
ma, okA := a.(map[string]any)
|
||||
mb, okB := b.(map[string]any)
|
||||
if !okA || !okB {
|
||||
return a == b
|
||||
}
|
||||
if len(ma) != len(mb) {
|
||||
return false
|
||||
}
|
||||
for k, va := range ma {
|
||||
vb, ok := mb[k]
|
||||
if !ok || !mapEqual(va, vb) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type openAIRequestBody struct {
|
||||
Tools []json.RawMessage `json:"tools,omitempty"`
|
||||
Messages []map[string]any `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
StreamOptions map[string]any `json:"stream_options"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
@@ -41,7 +41,7 @@ type openAIResponsesRequestBody struct {
|
||||
Input []map[string]any `json:"input"`
|
||||
Tools []map[string]any `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Reasoning *openAIResponsesReasoning `json:"reasoning,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
@@ -50,6 +50,7 @@ type openAIResponsesRequestBody struct {
|
||||
|
||||
type openAIResponsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type openAIToolAccumulator struct {
|
||||
@@ -304,15 +305,67 @@ func OpenAIEndpointURL(baseURL string, endpoint string) string {
|
||||
if !strings.HasPrefix(normalizedEndpoint, "/") {
|
||||
normalizedEndpoint = "/" + normalizedEndpoint
|
||||
}
|
||||
// 规则0:自定义路径模式
|
||||
// - baseURL 已含 endpoint 后缀(/chat/completions 或 /responses)→ 直接用 base
|
||||
// - 否则追加 /chat/completions(默认协议形态,覆盖 Z.AI /v4 等场景)
|
||||
if normalizedEndpoint == modelchannel.OpenAIEndpointCustom {
|
||||
if OpenAIEndpointFromBaseURL(base) != "" {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") && strings.HasPrefix(normalizedEndpoint, "/v1/") {
|
||||
return base + strings.TrimPrefix(normalizedEndpoint, "/v1")
|
||||
return base + "/chat/completions"
|
||||
}
|
||||
// 规则1:baseURL 已含 endpoint 后缀 → 直接用 base
|
||||
if OpenAIEndpointFromBaseURL(base) != "" {
|
||||
return base
|
||||
}
|
||||
// 规则2:baseURL 以 /vN 结尾时,剥离 endpoint 的版本前缀(/v1/、/v2/ 等)
|
||||
// 这样 base=.../v4 + endpoint=/v1/chat/completions → .../v4/chat/completions
|
||||
if _, ok := trailingVersionSegment(base); ok {
|
||||
if rest, stripped := stripEndpointVersionPrefix(normalizedEndpoint); stripped {
|
||||
return base + rest
|
||||
}
|
||||
}
|
||||
// 规则3:兜底原样拼接
|
||||
return base + normalizedEndpoint
|
||||
}
|
||||
|
||||
// trailingVersionSegment 检测 URL 末尾是否以 /vN 形式结尾(N 为数字),
|
||||
// 返回版本段(如 "v4")和是否匹配。用于通用版本段去重。
|
||||
func trailingVersionSegment(base string) (string, bool) {
|
||||
idx := strings.LastIndex(base, "/")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
seg := base[idx+1:]
|
||||
if len(seg) < 2 || seg[0] != 'v' {
|
||||
return "", false
|
||||
}
|
||||
for i := 1; i < len(seg); i++ {
|
||||
if seg[i] < '0' || seg[i] > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return seg, true
|
||||
}
|
||||
|
||||
// stripEndpointVersionPrefix 剥离 endpoint 路径开头的版本段前缀(/vN/),
|
||||
// 返回剩余路径和是否成功剥离。
|
||||
// /v1/chat/completions → ("/chat/completions", true)
|
||||
// /chat/completions → ("", false)
|
||||
func stripEndpointVersionPrefix(endpoint string) (string, bool) {
|
||||
if len(endpoint) < 4 || endpoint[0] != '/' || endpoint[1] != 'v' {
|
||||
return "", false
|
||||
}
|
||||
i := 2
|
||||
for i < len(endpoint) && endpoint[i] >= '0' && endpoint[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 2 || i >= len(endpoint) || endpoint[i] != '/' {
|
||||
return "", false
|
||||
}
|
||||
return endpoint[i:], true
|
||||
}
|
||||
|
||||
func ResolveOpenAIEndpoint(baseURL string, endpoint string) string {
|
||||
if endpointFromURL := OpenAIEndpointFromBaseURL(baseURL); endpointFromURL != "" {
|
||||
return endpointFromURL
|
||||
@@ -377,11 +430,11 @@ func (adapter *OpenAIAdapter) Stream(ctx context.Context, req StreamRequest, sin
|
||||
req.OpenAIEndpoint = endpoint
|
||||
if req.RequestKnobs != nil {
|
||||
req.RequestKnobs["openai_endpoint"] = endpoint
|
||||
if endpoint == modelchannel.OpenAIEndpointResponses {
|
||||
if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
|
||||
req.RequestKnobs["max_output_tokens"] = req.MaxTokens
|
||||
}
|
||||
}
|
||||
if endpoint == modelchannel.OpenAIEndpointResponses {
|
||||
if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
|
||||
return adapter.streamResponses(ctx, req, baseURL, apiKey, modelID, sink)
|
||||
}
|
||||
return adapter.streamChatCompletions(ctx, req, baseURL, apiKey, modelID, sink)
|
||||
@@ -403,9 +456,11 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
|
||||
Model: modelID,
|
||||
Messages: normalizedMessages,
|
||||
Stream: true,
|
||||
MaxTokens: req.MaxTokens,
|
||||
StreamOptions: map[string]any{"include_usage": true},
|
||||
}
|
||||
if shouldSendOpenAIMaxOutputTokens(modelID) {
|
||||
requestBody.MaxTokens = req.MaxTokens
|
||||
}
|
||||
if key := openAIPromptCacheKey(req, modelID); key != "" {
|
||||
requestBody.PromptCacheKey = key
|
||||
}
|
||||
@@ -425,14 +480,14 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
|
||||
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
|
||||
return err
|
||||
}
|
||||
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, modelchannel.OpenAIEndpointChatCompletions)
|
||||
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, req.OpenAIEndpoint)
|
||||
if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil {
|
||||
finishedAt = time.Now().UTC()
|
||||
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
|
||||
return err
|
||||
}
|
||||
body = bodyMap
|
||||
requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointChatCompletions)
|
||||
requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint)
|
||||
recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
@@ -785,7 +840,7 @@ func (adapter *OpenAIAdapter) streamChatCompletions(ctx context.Context, req Str
|
||||
}
|
||||
}
|
||||
|
||||
if choice.FinishReason != nil {
|
||||
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
|
||||
if err := flushTaggedContentTail(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
@@ -866,9 +921,11 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
|
||||
Instructions: instructions,
|
||||
Input: input,
|
||||
Stream: true,
|
||||
MaxOutputTokens: req.MaxTokens,
|
||||
Store: false,
|
||||
}
|
||||
if shouldSendOpenAIMaxOutputTokens(modelID) {
|
||||
requestBody.MaxOutputTokens = req.MaxTokens
|
||||
}
|
||||
if key := openAIPromptCacheKey(req, modelID); key != "" {
|
||||
requestBody.PromptCacheKey = key
|
||||
}
|
||||
@@ -888,7 +945,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
|
||||
requestBody.Tools = tools
|
||||
}
|
||||
if effort := strings.TrimSpace(req.ReasoningEffort); effort != "" {
|
||||
requestBody.Reasoning = &openAIResponsesReasoning{Effort: effort}
|
||||
requestBody.Reasoning = &openAIResponsesReasoning{Effort: effort, Summary: "auto"}
|
||||
requestBody.Include = []string{"reasoning.encrypted_content"}
|
||||
}
|
||||
body = requestBody
|
||||
@@ -901,7 +958,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
|
||||
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
|
||||
return err
|
||||
}
|
||||
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, modelchannel.OpenAIEndpointResponses)
|
||||
applyOpenAIThinkingDisable(bodyMap, req, baseURL, modelID, req.OpenAIEndpoint)
|
||||
if err := ApplyOpenAIExtraParams(bodyMap, req.OpenAIExtraParamsEnabled, req.OpenAIExtraParamsJSON); err != nil {
|
||||
finishedAt = time.Now().UTC()
|
||||
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "openai", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
|
||||
@@ -909,7 +966,7 @@ func (adapter *OpenAIAdapter) streamResponses(ctx context.Context, req StreamReq
|
||||
}
|
||||
body = bodyMap
|
||||
|
||||
requestURL := OpenAIEndpointURL(baseURL, modelchannel.OpenAIEndpointResponses)
|
||||
requestURL := OpenAIEndpointURL(baseURL, req.OpenAIEndpoint)
|
||||
recordLLMRequestArtifact(req, "openai", modelID, "POST", requestURL, body)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
@@ -1792,6 +1849,10 @@ func normalizeOpenAIProviderMessages(messages []Message, thinkingEnabled bool) (
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func shouldSendOpenAIMaxOutputTokens(modelID string) bool {
|
||||
return !strings.Contains(strings.ToLower(strings.TrimSpace(modelID)), "gpt")
|
||||
}
|
||||
|
||||
func shouldIncludeOpenAIReasoningContent(message Message, thinkingEnabled bool) bool {
|
||||
if strings.TrimSpace(message.ReasoningContent) != "" {
|
||||
return true
|
||||
@@ -1819,7 +1880,7 @@ func applyOpenAIThinkingDisable(body map[string]any, req StreamRequest, baseURL
|
||||
delete(body, "reasoning_effort")
|
||||
setRequestKnob(req, "thinking_disabled_provider_param", "enable_thinking")
|
||||
case "reasoning_none":
|
||||
if endpoint == modelchannel.OpenAIEndpointResponses {
|
||||
if modelchannel.OpenAIEndpointShape(endpoint) == "responses" {
|
||||
body["reasoning"] = map[string]any{"effort": "none"}
|
||||
} else {
|
||||
body["reasoning_effort"] = "none"
|
||||
@@ -1841,10 +1902,15 @@ func openAIThinkingDisableKind(baseURL string, modelID string, endpoint string)
|
||||
strings.Contains(base, "bigmodel") ||
|
||||
strings.Contains(base, "z.ai") ||
|
||||
strings.Contains(base, "zhipu") ||
|
||||
strings.Contains(base, "xiaomimimo") ||
|
||||
strings.Contains(base, "mimo") ||
|
||||
strings.Contains(base, "minimax") ||
|
||||
strings.Contains(model, "deepseek") ||
|
||||
strings.Contains(model, "glm") ||
|
||||
strings.Contains(model, "zai") ||
|
||||
strings.Contains(model, "zhipu"):
|
||||
strings.Contains(model, "zhipu") ||
|
||||
strings.Contains(model, "mimo") ||
|
||||
strings.Contains(model, "minimax"):
|
||||
return "thinking_type"
|
||||
case openAIModelSupportsReasoningNone(model):
|
||||
return "reasoning_none"
|
||||
@@ -1902,10 +1968,18 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
|
||||
}
|
||||
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
|
||||
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
|
||||
var output any = openAIResponsesMessageText(message)
|
||||
if hasImageContentParts(message.ContentParts) {
|
||||
content, err := openAIResponsesMessageContent(message, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
output = content
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"type": "function_call_output",
|
||||
"call_id": callID,
|
||||
"output": openAIResponsesMessageText(message),
|
||||
"output": output,
|
||||
})
|
||||
activeAssistantReasoningKey = ""
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIResponsesRequestsReasoningSummary(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"check the request\"}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"model\":\"gpt-5.6\",\"status\":\"completed\",\"output_text\":\"done\"}}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
events := make([]ModelEvent, 0, 4)
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "gpt-5.6",
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
ReasoningEffort: "high",
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
MaxTokens: 128,
|
||||
}, func(event ModelEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
reasoning, ok := requestBody["reasoning"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("reasoning request body missing: %#v", requestBody)
|
||||
}
|
||||
if got := reasoning["effort"]; got != "high" {
|
||||
t.Fatalf("reasoning.effort = %#v, want high", got)
|
||||
}
|
||||
if got := reasoning["summary"]; got != "auto" {
|
||||
t.Fatalf("reasoning.summary = %#v, want auto", got)
|
||||
}
|
||||
include, ok := requestBody["include"].([]any)
|
||||
if !ok || len(include) != 1 || include[0] != "reasoning.encrypted_content" {
|
||||
t.Fatalf("reasoning include = %#v, want encrypted content", requestBody["include"])
|
||||
}
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindTextDelta, 1)
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesOmitsReasoningWhenEffortBlank(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"model\":\"grok-composer-2.5-fast\",\"status\":\"completed\",\"output_text\":\"done\"}}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "grok-composer-2.5-fast",
|
||||
OpenAIEndpoint: "/v1/responses",
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
MaxTokens: 128,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
if _, exists := requestBody["reasoning"]; exists {
|
||||
t.Fatalf("reasoning should be omitted when effort is blank: %#v", requestBody["reasoning"])
|
||||
}
|
||||
if _, exists := requestBody["reasoning_effort"]; exists {
|
||||
t.Fatalf("reasoning_effort should be omitted when effort is blank: %#v", requestBody["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsOmitsReasoningWhenEffortBlank(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(writer, "data: {\"model\":\"grok-composer-2.5-fast\",\"choices\":[{\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "grok-composer-2.5-fast",
|
||||
OpenAIEndpoint: "/v1/chat/completions",
|
||||
Messages: []Message{{Role: "user", Content: "hello"}},
|
||||
MaxTokens: 128,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{"reasoning_effort", "reasoning", "include"} {
|
||||
if value, exists := requestBody[field]; exists {
|
||||
t.Fatalf("%s should be omitted when effort is blank: %#v", field, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsIgnoresBlankFinishReason(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
chunks := []string{
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":"first"},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"reasoning_content":" second"},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"Ls","arguments":""}}]},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{\"path\":"}}]},"finish_reason":""}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"\"/tmp\"}"}}]},"finish_reason":"tool_calls"}]}`,
|
||||
`{"model":"deepseek-v4-flash","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":7}}`,
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
_, _ = fmt.Fprintf(writer, "data: %s\n\n", chunk)
|
||||
}
|
||||
_, _ = fmt.Fprint(writer, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := &OpenAIAdapter{client: server.Client()}
|
||||
events := make([]ModelEvent, 0, 8)
|
||||
err := adapter.Stream(context.Background(), StreamRequest{
|
||||
RequestID: "request-1",
|
||||
RunID: "run-1",
|
||||
ModelCallID: "model-call-1",
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
ProviderModelID: "deepseek-v4-flash",
|
||||
OpenAIEndpoint: "/v1/chat/completions",
|
||||
Messages: []Message{{Role: "user", Content: "list files"}},
|
||||
MaxTokens: 128,
|
||||
RequestKnobs: map[string]any{},
|
||||
}, func(event ModelEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream failed: %v", err)
|
||||
}
|
||||
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingDelta, 2)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindThinkingCompleted, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindToolLikeCompleted, 1)
|
||||
assertOpenAIEventKindCount(t, events, ModelEventKindTurnFinished, 1)
|
||||
|
||||
toolEvent := firstOpenAIEventForTest(events, ModelEventKindToolLikeCompleted)
|
||||
if toolEvent == nil || toolEvent.ToolInvocation == nil {
|
||||
t.Fatalf("completed tool invocation missing: %#v", toolEvent)
|
||||
}
|
||||
if toolEvent.ToolInvocation.ToolName != "Ls" {
|
||||
t.Fatalf("tool name = %q, want Ls", toolEvent.ToolInvocation.ToolName)
|
||||
}
|
||||
if got := string(toolEvent.ToolInvocation.ArgsJSON); got != `{"path":"/tmp"}` {
|
||||
t.Fatalf("tool args = %q, want %q", got, `{"path":"/tmp"}`)
|
||||
}
|
||||
|
||||
finished := firstOpenAIEventForTest(events, ModelEventKindTurnFinished)
|
||||
if finished == nil || finished.FinishReason != "tool_calls" {
|
||||
t.Fatalf("finish reason = %q, want tool_calls", finished.FinishReason)
|
||||
}
|
||||
if finished.InputTokens != 12 || finished.OutputTokens != 7 {
|
||||
t.Fatalf("usage = input:%d output:%d, want input:12 output:7", finished.InputTokens, finished.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOpenAIEventKindCount(t *testing.T, events []ModelEvent, kind ModelEventKind, want int) {
|
||||
t.Helper()
|
||||
got := 0
|
||||
for _, event := range events {
|
||||
if event.Kind == kind {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("event kind %s count = %d, want %d; events=%#v", kind, got, want, events)
|
||||
}
|
||||
}
|
||||
|
||||
func firstOpenAIEventForTest(events []ModelEvent, kind ModelEventKind) *ModelEvent {
|
||||
for index := range events {
|
||||
if events[index].Kind == kind {
|
||||
return &events[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package modeladapter
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOpenAIThinkingDisableKindMiniMax verifies that the MiniMax OpenAI-compatible
|
||||
// endpoint is routed to the thinking_type disable branch, so that disabling
|
||||
// thinking writes thinking:{type:"disabled"} and drops reasoning_effort. This
|
||||
// covers the global endpoint, the China endpoint and a custom proxy that only
|
||||
// exposes the MiniMax model id, plus regression cases for the other branches.
|
||||
func TestOpenAIThinkingDisableKindMiniMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
modelID string
|
||||
endpoint string
|
||||
want string
|
||||
}{
|
||||
// MiniMax global endpoint
|
||||
{name: "minimax global base + M3", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "minimax global base + M2.7", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// MiniMax China endpoint
|
||||
{name: "minimax cn base + M3", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "minimax cn base + M2.7", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M2.7", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// MiniMax model id only (custom base)
|
||||
{name: "minimax model only (custom base)", baseURL: "https://custom.proxy.example.com/v1", modelID: "MiniMax-M3", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// Regression: enable_thinking branch (qwen) is unaffected
|
||||
{name: "qwen via dashscope", baseURL: "https://dashscope.aliyuncs.com/v1", modelID: "qwen-max", endpoint: "/chat/completions", want: "enable_thinking"},
|
||||
// Regression: reasoning_none branch (gpt-5.1+/gpt-6) is unaffected
|
||||
{name: "gpt-6", baseURL: "https://api.openai.com/v1", modelID: "gpt-6", endpoint: "/chat/completions", want: "reasoning_none"},
|
||||
// Regression: unknown provider does not disable
|
||||
{name: "unknown provider", baseURL: "https://api.unknown-llm.com/v1", modelID: "some-model", endpoint: "/chat/completions", want: ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := openAIThinkingDisableKind(tc.baseURL, tc.modelID, tc.endpoint)
|
||||
if got != tc.want {
|
||||
t.Fatalf("openAIThinkingDisableKind(%q, %q, %q) = %q, want %q", tc.baseURL, tc.modelID, tc.endpoint, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiniMax verifies that when ThinkingEffort=disabled
|
||||
// and the provider is MiniMax, applyOpenAIThinkingDisable writes
|
||||
// thinking:{type:"disabled"} and deletes reasoning_effort on both the global and
|
||||
// the China endpoint.
|
||||
func TestApplyOpenAIThinkingDisableMiniMax(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
modelID string
|
||||
}{
|
||||
{name: "global endpoint", baseURL: "https://api.minimax.io/v1", modelID: "MiniMax-M3"},
|
||||
{name: "china endpoint", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M3"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "disabled", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{
|
||||
"model": tc.modelID,
|
||||
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
applyOpenAIThinkingDisable(body, req, tc.baseURL, tc.modelID, "/chat/completions")
|
||||
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected body[thinking] to be map[string]any, got %T (%v)", body["thinking"], body["thinking"])
|
||||
}
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("expected thinking.type=disabled, got %v", thinking["type"])
|
||||
}
|
||||
if _, stillPresent := body["reasoning_effort"]; stillPresent {
|
||||
t.Fatalf("reasoning_effort should be deleted when thinking disabled, got %v", body["reasoning_effort"])
|
||||
}
|
||||
if got := req.RequestKnobs["thinking_disabled_provider_param"]; got != "thinking.type" {
|
||||
t.Fatalf("expected request knob thinking_disabled_provider_param=thinking.type, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiniMaxNotTriggered verifies that a non-disabled
|
||||
// thinking effort does not inject the disable field for MiniMax.
|
||||
func TestApplyOpenAIThinkingDisableMiniMaxNotTriggered(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "high", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{"model": "MiniMax-M3", "reasoning_effort": "high"}
|
||||
applyOpenAIThinkingDisable(body, req, "https://api.minimax.io/v1", "MiniMax-M3", "/chat/completions")
|
||||
if _, present := body["thinking"]; present {
|
||||
t.Fatalf("thinking should not be injected when ThinkingEffort != disabled, got %v", body["thinking"])
|
||||
}
|
||||
if body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("reasoning_effort should be preserved when not disabled, got %v", body["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAIThinkingDisableKindMiMo 验证小米 MiMo(base_url 含 xiaomimimo/mimo,
|
||||
// model 含 mimo)被识别为 thinking_type 分支,从而在用户禁用思考时正确写入
|
||||
// thinking:{type:"disabled"}。回归覆盖 deepseek/glm/qwen/gpt-5 等既有分支。
|
||||
func TestOpenAIThinkingDisableKindMiMo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
modelID string
|
||||
endpoint string
|
||||
want string
|
||||
}{
|
||||
// MiMo —— 修复目标
|
||||
{name: "mimo official base + pro model", baseURL: "https://api.xiaomimimo.com/v1", modelID: "mimo-v2.5-pro", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "mimo base by host keyword", baseURL: "https://api.xiaomimimo.com/v1", modelID: "mimo-v2.5", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "mimo model only (custom base)", baseURL: "https://custom.proxy.example.com/v1", modelID: "mimo-v2.5-pro", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "mimo ultraspeed variant", baseURL: "https://api.xiaomimimo.com/v1", modelID: "mimo-v2.5-pro-ultraspeed", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// 回归:既有 thinking_type 分支不受影响
|
||||
{name: "deepseek base", baseURL: "https://api.deepseek.com/v1", modelID: "deepseek-chat", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "glm model via zhipu base", baseURL: "https://open.bigmodel.cn/api/paas/v4", modelID: "glm-4.6", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
{name: "z.ai base", baseURL: "https://api.z.ai/api/paas/v4", modelID: "glm-4.5", endpoint: "/chat/completions", want: "thinking_type"},
|
||||
// 回归:enable_thinking 分支(qwen 系)
|
||||
{name: "qwen via dashscope", baseURL: "https://dashscope.aliyuncs.com/v1", modelID: "qwen-max", endpoint: "/chat/completions", want: "enable_thinking"},
|
||||
{name: "qwen model keyword", baseURL: "https://custom.example.com/v1", modelID: "qwen3-coder", endpoint: "/chat/completions", want: "enable_thinking"},
|
||||
// 回归:reasoning_none 分支(gpt-5.1+/gpt-6)
|
||||
{name: "gpt-5.1", baseURL: "https://api.openai.com/v1", modelID: "gpt-5.1", endpoint: "/chat/completions", want: "reasoning_none"},
|
||||
{name: "gpt-6", baseURL: "https://api.openai.com/v1", modelID: "gpt-6", endpoint: "/chat/completions", want: "reasoning_none"},
|
||||
// 回归:未知 provider 不做 disable
|
||||
{name: "unknown provider", baseURL: "https://api.unknown-llm.com/v1", modelID: "some-model", endpoint: "/chat/completions", want: ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := openAIThinkingDisableKind(tc.baseURL, tc.modelID, tc.endpoint)
|
||||
if got != tc.want {
|
||||
t.Fatalf("openAIThinkingDisableKind(%q, %q, %q) = %q, want %q", tc.baseURL, tc.modelID, tc.endpoint, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiMo 验证当 ThinkingEffort=disabled 且 provider
|
||||
// 为 MiMo 时,applyOpenAIThinkingDisable 会写入 thinking:{type:"disabled"} 并删除
|
||||
// reasoning_effort,与 deepseek/glm 行为一致。
|
||||
func TestApplyOpenAIThinkingDisableMiMo(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "disabled", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{
|
||||
"model": "mimo-v2.5-pro",
|
||||
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
applyOpenAIThinkingDisable(body, req, "https://api.xiaomimimo.com/v1", "mimo-v2.5-pro", "/chat/completions")
|
||||
|
||||
thinking, ok := body["thinking"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected body[thinking] to be map[string]any, got %T (%v)", body["thinking"], body["thinking"])
|
||||
}
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("expected thinking.type=disabled, got %v", thinking["type"])
|
||||
}
|
||||
if _, stillPresent := body["reasoning_effort"]; stillPresent {
|
||||
t.Fatalf("reasoning_effort should be deleted when thinking disabled for mimo, got %v", body["reasoning_effort"])
|
||||
}
|
||||
if got := req.RequestKnobs["thinking_disabled_provider_param"]; got != "thinking.type" {
|
||||
t.Fatalf("expected request knob thinking_disabled_provider_param=thinking.type, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyOpenAIThinkingDisableMiMoNotTriggered 验证非 disabled 时不会误写 disable 字段。
|
||||
func TestApplyOpenAIThinkingDisableMiMoNotTriggered(t *testing.T) {
|
||||
req := StreamRequest{ThinkingEffort: "high", RequestKnobs: map[string]any{}}
|
||||
body := map[string]any{"model": "mimo-v2.5-pro", "reasoning_effort": "high"}
|
||||
applyOpenAIThinkingDisable(body, req, "https://api.xiaomimimo.com/v1", "mimo-v2.5-pro", "/chat/completions")
|
||||
if _, present := body["thinking"]; present {
|
||||
t.Fatalf("thinking should not be injected when ThinkingEffort != disabled, got %v", body["thinking"])
|
||||
}
|
||||
if body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("reasoning_effort should be preserved when not disabled, got %v", body["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func canMergeProviderAssistantToolCalls(last Message, current Message) bool {
|
||||
if strings.TrimSpace(last.Role) != "assistant" || strings.TrimSpace(current.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if len(last.ToolCalls) == 0 || len(current.ToolCalls) == 0 {
|
||||
if len(current.ToolCalls) == 0 {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(last.ToolCallID) != "" || strings.TrimSpace(last.Name) != "" {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
)
|
||||
|
||||
type recordingModelAdapter struct {
|
||||
request StreamRequest
|
||||
}
|
||||
|
||||
func (adapter *recordingModelAdapter) Stream(_ context.Context, req StreamRequest, _ func(ModelEvent) error) error {
|
||||
adapter.request = req
|
||||
return nil
|
||||
}
|
||||
|
||||
type staticChannelResolver struct {
|
||||
channel *legacyruntime.ResolvedChannel
|
||||
}
|
||||
|
||||
func (resolver staticChannelResolver) SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error) {
|
||||
return resolver.channel, nil
|
||||
}
|
||||
|
||||
func (staticChannelResolver) ProviderStreamIdleTimeout(context.Context) time.Duration {
|
||||
return time.Second
|
||||
}
|
||||
|
||||
func TestRouterRuntimeDisabledClearsReasoningEffort(t *testing.T) {
|
||||
openAI := &recordingModelAdapter{}
|
||||
router := &Router{
|
||||
openai: openAI,
|
||||
resolver: staticChannelResolver{channel: &legacyruntime.ResolvedChannel{
|
||||
ID: "channel-a",
|
||||
Provider: "openai",
|
||||
Model: "grok-composer-2.5-fast",
|
||||
ReasoningEffort: "medium",
|
||||
}},
|
||||
}
|
||||
requestKnobs := map[string]any{"reasoning_effort": "medium"}
|
||||
|
||||
err := router.Stream(context.Background(), StreamRequest{
|
||||
ModelID: "channel-a",
|
||||
ThinkingEffort: "disabled",
|
||||
RequestKnobs: requestKnobs,
|
||||
}, func(ModelEvent) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("Stream returned error: %v", err)
|
||||
}
|
||||
if got := openAI.request.ReasoningEffort; got != "" {
|
||||
t.Fatalf("ReasoningEffort = %q, want blank", got)
|
||||
}
|
||||
if _, exists := openAI.request.RequestKnobs["reasoning_effort"]; exists {
|
||||
t.Fatalf("reasoning_effort knob should be removed: %#v", openAI.request.RequestKnobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeProviderMessagesMergesLegacyAssistantTextAndToolCallTurnsIdempotently(t *testing.T) {
|
||||
input := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Now let me pass stream.Mode in service.go.",
|
||||
ReasoningContent: "I need to update service.go.",
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ReasoningContent: "I need to update service.go.",
|
||||
ToolCalls: []ToolCallDescriptor{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: ToolCallFunctionShape{
|
||||
Name: "PatchEdit",
|
||||
Arguments: `{"path":"/workspace/service.go"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
Content: `{"success":{"path":"/workspace/service.go"}}`,
|
||||
ToolCallID: "call_1",
|
||||
Name: "PatchEdit",
|
||||
},
|
||||
}
|
||||
|
||||
first := sanitizeProviderMessages(input)
|
||||
if len(first) != 2 {
|
||||
t.Fatalf("message count = %d, want 2: %#v", len(first), first)
|
||||
}
|
||||
|
||||
assistant := first[0]
|
||||
if assistant.Content != input[0].Content {
|
||||
t.Fatalf("assistant content = %q", assistant.Content)
|
||||
}
|
||||
if assistant.ReasoningContent != input[0].ReasoningContent {
|
||||
t.Fatalf("assistant reasoning = %q, want one copy of %q", assistant.ReasoningContent, input[0].ReasoningContent)
|
||||
}
|
||||
if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].ID != "call_1" {
|
||||
t.Fatalf("assistant tool calls = %#v", assistant.ToolCalls)
|
||||
}
|
||||
|
||||
second := sanitizeProviderMessages(first)
|
||||
if !reflect.DeepEqual(second, first) {
|
||||
t.Fatalf("sanitizing normalized messages changed them:\nfirst: %#v\nsecond: %#v", first, second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolImageProviderEncodings(t *testing.T) {
|
||||
message := toolImageMessageForTest()
|
||||
|
||||
t.Run("openai_chat", func(t *testing.T) {
|
||||
items, err := normalizeOpenAIProviderMessages([]Message{message}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIProviderMessages() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0]["role"] != "tool" || items[0]["tool_call_id"] != "call-1" {
|
||||
t.Fatalf("openai chat tool message = %#v", items)
|
||||
}
|
||||
content, ok := items[0]["content"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("openai chat content = %#v", items[0]["content"])
|
||||
}
|
||||
imageURL, ok := content[1]["image_url"].(map[string]any)
|
||||
if content[1]["type"] != "image_url" || !ok || !strings.HasPrefix(imageURL["url"].(string), "data:image/png;base64,") {
|
||||
t.Fatalf("openai chat image part = %#v", content[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("openai_responses", func(t *testing.T) {
|
||||
_, items, err := normalizeOpenAIResponsesInput([]Message{message})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOpenAIResponsesInput() error = %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0]["type"] != "function_call_output" {
|
||||
t.Fatalf("openai responses items = %#v", items)
|
||||
}
|
||||
content, ok := items[0]["output"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("openai responses output = %#v", items[0]["output"])
|
||||
}
|
||||
if content[0]["type"] != "input_text" || content[1]["type"] != "input_image" {
|
||||
t.Fatalf("openai responses content = %#v", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anthropic", func(t *testing.T) {
|
||||
_, messages, err := normalizeAnthropicProviderMessages([]Message{message}, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAnthropicProviderMessages() error = %v", err)
|
||||
}
|
||||
if len(messages) != 1 || messages[0].Role != "user" || len(messages[0].Content) != 1 {
|
||||
t.Fatalf("anthropic messages = %#v", messages)
|
||||
}
|
||||
toolResult := messages[0].Content[0]
|
||||
if toolResult["type"] != "tool_result" || toolResult["tool_use_id"] != "call-1" {
|
||||
t.Fatalf("anthropic tool result = %#v", toolResult)
|
||||
}
|
||||
content, ok := toolResult["content"].([]map[string]any)
|
||||
if !ok || len(content) != 2 {
|
||||
t.Fatalf("anthropic tool content = %#v", toolResult["content"])
|
||||
}
|
||||
if content[0]["type"] != "text" || content[1]["type"] != "image" {
|
||||
t.Fatalf("anthropic content blocks = %#v", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func toolImageMessageForTest() Message {
|
||||
return Message{
|
||||
Role: "tool",
|
||||
Content: "read binary bytes=16",
|
||||
ToolCallID: "call-1",
|
||||
Name: "Read",
|
||||
ContentParts: []ContentPart{
|
||||
{Type: "text", Text: "read binary bytes=16"},
|
||||
{
|
||||
Type: "image",
|
||||
Image: &ImageContent{
|
||||
MIMEType: "image/png",
|
||||
Path: "diagram.png",
|
||||
Data: []byte("\x89PNG\r\n\x1a\nimage"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -897,8 +897,8 @@ func buildMessagesFromPendingAssistantRaw(raw string) []Message {
|
||||
reasoningSignature := ""
|
||||
pendingToolCalls := make([]ToolCallDescriptor, 0, len(payload.Content))
|
||||
pendingToolResults := make([]Message, 0, len(payload.Content))
|
||||
flushAssistantText := func(forceReasoningOnly bool) {
|
||||
if len(textParts) == 0 && (!forceReasoningOnly || len(reasoningParts) == 0) {
|
||||
flushAssistantTurn := func() {
|
||||
if len(textParts) == 0 && len(reasoningParts) == 0 && len(pendingToolCalls) == 0 {
|
||||
return
|
||||
}
|
||||
messages = append(messages, Message{
|
||||
@@ -906,23 +906,10 @@ func buildMessagesFromPendingAssistantRaw(raw string) []Message {
|
||||
Content: formatMessageText(strings.Join(textParts, "")),
|
||||
ReasoningContent: strings.Join(reasoningParts, ""),
|
||||
ReasoningSignature: reasoningSignature,
|
||||
})
|
||||
textParts = textParts[:0]
|
||||
reasoningParts = reasoningParts[:0]
|
||||
reasoningSignature = ""
|
||||
}
|
||||
flushToolCalls := func() {
|
||||
if len(pendingToolCalls) == 0 {
|
||||
return
|
||||
}
|
||||
messages = append(messages, Message{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ReasoningContent: strings.Join(reasoningParts, ""),
|
||||
ReasoningSignature: reasoningSignature,
|
||||
ToolCalls: append([]ToolCallDescriptor(nil), pendingToolCalls...),
|
||||
})
|
||||
messages = append(messages, pendingToolResults...)
|
||||
textParts = textParts[:0]
|
||||
reasoningParts = reasoningParts[:0]
|
||||
reasoningSignature = ""
|
||||
pendingToolCalls = pendingToolCalls[:0]
|
||||
@@ -932,8 +919,6 @@ func buildMessagesFromPendingAssistantRaw(raw string) []Message {
|
||||
for _, item := range payload.Content {
|
||||
switch strings.TrimSpace(item.Type) {
|
||||
case "reasoning":
|
||||
flushToolCalls()
|
||||
flushAssistantText(false)
|
||||
if item.Text != "" {
|
||||
reasoningParts = append(reasoningParts, item.Text)
|
||||
}
|
||||
@@ -941,12 +926,10 @@ func buildMessagesFromPendingAssistantRaw(raw string) []Message {
|
||||
reasoningSignature = strings.TrimSpace(item.Signature)
|
||||
}
|
||||
case "text":
|
||||
flushToolCalls()
|
||||
if item.Text != "" {
|
||||
textParts = append(textParts, item.Text)
|
||||
}
|
||||
case "tool-call":
|
||||
flushAssistantText(false)
|
||||
pendingToolCalls = append(pendingToolCalls, ToolCallDescriptor{
|
||||
ID: strings.TrimSpace(item.ToolCallID),
|
||||
Index: len(pendingToolCalls),
|
||||
@@ -966,9 +949,7 @@ func buildMessagesFromPendingAssistantRaw(raw string) []Message {
|
||||
}
|
||||
}
|
||||
}
|
||||
flushAssistantText(false)
|
||||
flushToolCalls()
|
||||
flushAssistantText(true)
|
||||
flushAssistantTurn()
|
||||
return messages
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,46 @@ func buildUserReplayMessage(text string, selectedContext *agentv1.SelectedContex
|
||||
}, true
|
||||
}
|
||||
|
||||
// BuildSelectedCursorCommandsReplayMessage renders command content for new history entries.
|
||||
// Keeping this separate from BuildUserMessageReplayMessage prevents old user_message entries
|
||||
// from changing their model-visible meaning after a backend upgrade.
|
||||
func BuildSelectedCursorCommandsReplayMessage(userMessage *agentv1.UserMessage) (Message, bool) {
|
||||
if userMessage == nil {
|
||||
return Message{}, false
|
||||
}
|
||||
content := buildSelectedCursorCommandsPromptSection(userMessage.GetSelectedContext())
|
||||
if content == "" {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{Role: "user", Content: content}, true
|
||||
}
|
||||
|
||||
func buildSelectedCursorCommandsPromptSection(selectedContext *agentv1.SelectedContext) string {
|
||||
if selectedContext == nil || len(selectedContext.GetCursorCommands()) == 0 {
|
||||
return ""
|
||||
}
|
||||
entries := make([]string, 0, len(selectedContext.GetCursorCommands()))
|
||||
for _, command := range selectedContext.GetCursorCommands() {
|
||||
if command == nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(command.GetContent())
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(command.GetName())
|
||||
if name == "" {
|
||||
entries = append(entries, "<cursor_command>\n"+content+"\n</cursor_command>")
|
||||
continue
|
||||
}
|
||||
entries = append(entries, fmt.Sprintf("<cursor_command name=\"%s\">\n%s\n</cursor_command>", escapePromptXML(name), content))
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "<cursor_commands>\n" + strings.Join(entries, "\n\n") + "\n</cursor_commands>"
|
||||
}
|
||||
|
||||
func buildSelectedIDEStatePromptSection(selectedContext *agentv1.SelectedContext) string {
|
||||
if selectedContext == nil || selectedContext.GetInvocationContext() == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package promptengine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildReplayMessagesFromPendingAssistantOutputsKeepsTextAndToolCallInOneAssistantTurn(t *testing.T) {
|
||||
raw := `{
|
||||
"id":"1",
|
||||
"role":"assistant",
|
||||
"content":[
|
||||
{"type":"reasoning","text":"I need to update service.go.","signature":"reasoning-signature"},
|
||||
{"type":"text","text":"Now let me pass stream.Mode in service.go."},
|
||||
{"type":"tool-call","toolCallId":"call_1","toolName":"PatchEdit","args":{"path":"/workspace/service.go","old_string":"old","new_string":"new"},"result":{"success":{"path":"/workspace/service.go"}}}
|
||||
]
|
||||
}`
|
||||
|
||||
first := BuildReplayMessagesFromPendingAssistantOutputs([]string{raw})
|
||||
if len(first) != 2 {
|
||||
t.Fatalf("message count = %d, want 2 (assistant tool-call turn plus tool result): %#v", len(first), first)
|
||||
}
|
||||
|
||||
assistant := first[0]
|
||||
if assistant.Role != "assistant" {
|
||||
t.Fatalf("assistant role = %q, want assistant", assistant.Role)
|
||||
}
|
||||
if assistant.Content != "Now let me pass stream.Mode in service.go." {
|
||||
t.Fatalf("assistant content = %q", assistant.Content)
|
||||
}
|
||||
if assistant.ReasoningContent != "I need to update service.go." {
|
||||
t.Fatalf("assistant reasoning = %q", assistant.ReasoningContent)
|
||||
}
|
||||
if assistant.ReasoningSignature != "reasoning-signature" {
|
||||
t.Fatalf("assistant reasoning signature = %q", assistant.ReasoningSignature)
|
||||
}
|
||||
if len(assistant.ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls = %d, want 1", len(assistant.ToolCalls))
|
||||
}
|
||||
if assistant.ToolCalls[0].ID != "call_1" || assistant.ToolCalls[0].Function.Name != "PatchEdit" {
|
||||
t.Fatalf("assistant tool call = %#v", assistant.ToolCalls[0])
|
||||
}
|
||||
|
||||
toolResult := first[1]
|
||||
if toolResult.Role != "tool" || toolResult.ToolCallID != "call_1" || toolResult.Name != "PatchEdit" {
|
||||
t.Fatalf("tool result = %#v", toolResult)
|
||||
}
|
||||
|
||||
second := BuildReplayMessagesFromPendingAssistantOutputs([]string{raw})
|
||||
if !reflect.DeepEqual(second, first) {
|
||||
t.Fatalf("rebuilding the same pending output changed messages:\nfirst: %#v\nsecond: %#v", first, second)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
TurnPhaseWaitingExternal TurnPhase = "waiting_external"
|
||||
TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
|
||||
TurnPhaseCompacting TurnPhase = "compacting"
|
||||
TurnPhaseCheckpointing TurnPhase = "checkpointing"
|
||||
TurnPhaseCompleted TurnPhase = "completed"
|
||||
TurnPhaseFailed TurnPhase = "failed"
|
||||
TurnPhaseCanceled TurnPhase = "canceled"
|
||||
@@ -66,6 +67,7 @@ const (
|
||||
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
|
||||
streamTimerShellForeground streamTimerKind = "shell_foreground"
|
||||
streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
|
||||
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
|
||||
streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
|
||||
)
|
||||
|
||||
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
|
||||
case streamCommandCancel:
|
||||
return service.handleCancelIntent(command.Intent)
|
||||
case streamCommandMetadata:
|
||||
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
|
||||
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
|
||||
}
|
||||
return service.handleMetadataIntent(command.Intent)
|
||||
case streamCommandExecResult:
|
||||
return service.handleExecResult(command.Intent)
|
||||
@@ -524,7 +529,7 @@ func (service *Service) applyProviderModelEvent(stream *ActiveStream, event mode
|
||||
stream.mu.Unlock()
|
||||
}
|
||||
if shouldEmitSyntheticThinking {
|
||||
if err := service.broker.Publish(requestID, StreamEvent{Message: buildThinkingDeltaMessage("Thinking is encrypted. Please wait a moment.", event.ThinkingStyle)}); err != nil {
|
||||
if err := service.broker.Publish(requestID, StreamEvent{Message: buildThinkingDeltaMessage("The reasoning process is encrypted. Please wait a moment. (This message does not affect any functionality; it only indicates the current reasoning status.)", event.ThinkingStyle)}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -725,6 +730,9 @@ func (service *Service) handleProviderDoneEvent(stream *ActiveStream, payload *s
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
return service.closeStreamWithProviderError(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, usage, providerErr, !hadToolInvocation)
|
||||
}
|
||||
if err := service.flushAssistantText(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, !hadToolInvocation); err != nil {
|
||||
return service.failStream(stream, "unknown", fmt.Errorf("flush failed provider output: %w", err))
|
||||
}
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
return service.failStream(stream, "unknown", payload.Err)
|
||||
}
|
||||
@@ -1003,6 +1011,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
|
||||
return nil
|
||||
}
|
||||
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
|
||||
case streamTimerCheckpointBlobs:
|
||||
return service.handleCheckpointBlobTimeout(stream)
|
||||
case streamTimerOrphanCancel:
|
||||
stream.mu.Lock()
|
||||
subscriberCount := len(stream.Subscribers)
|
||||
|
||||
@@ -2,6 +2,7 @@ package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -37,8 +38,9 @@ func (tracker *appendSequenceTracker) Acquire(ctx context.Context, requestID str
|
||||
if tracker == nil || strings.TrimSpace(requestID) == "" || appendSeq <= 0 {
|
||||
return appendSequenceTicket{}, false, nil
|
||||
}
|
||||
state := tracker.state(strings.TrimSpace(requestID))
|
||||
stale, err := state.acquire(ctx, appendSeq)
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
state := tracker.state(requestID)
|
||||
stale, err := state.acquire(ctx, requestID, appendSeq)
|
||||
if err != nil || stale {
|
||||
return appendSequenceTicket{}, stale, err
|
||||
}
|
||||
@@ -72,7 +74,7 @@ func (tracker *appendSequenceTracker) state(requestID string) *appendSequenceSta
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *appendSequenceState) acquire(ctx context.Context, appendSeq int64) (bool, error) {
|
||||
func (state *appendSequenceState) acquire(ctx context.Context, requestID string, appendSeq int64) (bool, error) {
|
||||
for {
|
||||
state.mu.Lock()
|
||||
now := time.Now().UTC()
|
||||
@@ -83,6 +85,29 @@ func (state *appendSequenceState) acquire(ctx context.Context, appendSeq int64)
|
||||
state.ready = make(chan struct{})
|
||||
}
|
||||
state.updatedAt = now
|
||||
|
||||
// Cursor may reuse the same request_id for a later turn and restart
|
||||
// append_seqno from 1. Accept that as a sequence restart when idle so
|
||||
// tool results are not discarded as stale forever.
|
||||
if appendSeq == 1 && state.next > 1 {
|
||||
if state.processing {
|
||||
ready := state.ready
|
||||
state.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
case <-ready:
|
||||
}
|
||||
continue
|
||||
}
|
||||
prevNext := state.next
|
||||
state.next = 1
|
||||
state.processing = true
|
||||
state.mu.Unlock()
|
||||
log.Printf("forwarder reset append sequence request_id=%s previous_next=%d append_seqno=1", requestID, prevNext)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case appendSeq < state.next:
|
||||
state.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendSequenceTrackerOrderedAcquire(t *testing.T) {
|
||||
tracker := newAppendSequenceTracker()
|
||||
ctx := context.Background()
|
||||
|
||||
ticket1, stale, err := tracker.Acquire(ctx, "req-1", 1)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq 1: stale=%v err=%v", stale, err)
|
||||
}
|
||||
ticket1.Release()
|
||||
|
||||
ticket2, stale, err := tracker.Acquire(ctx, "req-1", 2)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq 2: stale=%v err=%v", stale, err)
|
||||
}
|
||||
ticket2.Release()
|
||||
}
|
||||
|
||||
func TestAppendSequenceTrackerRestartFromOne(t *testing.T) {
|
||||
tracker := newAppendSequenceTracker()
|
||||
ctx := context.Background()
|
||||
|
||||
for seq := int64(1); seq <= 5; seq++ {
|
||||
ticket, stale, err := tracker.Acquire(ctx, "req-restart", seq)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq %d: stale=%v err=%v", seq, stale, err)
|
||||
}
|
||||
ticket.Release()
|
||||
}
|
||||
|
||||
// Same request_id, Cursor restarts append_seqno from 1 for a new turn.
|
||||
ticket, stale, err := tracker.Acquire(ctx, "req-restart", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("restart seq 1 err=%v", err)
|
||||
}
|
||||
if stale {
|
||||
t.Fatal("expected sequence restart on append_seqno=1 after progress")
|
||||
}
|
||||
ticket.Release()
|
||||
|
||||
ticket2, stale, err := tracker.Acquire(ctx, "req-restart", 2)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("post-restart seq 2: stale=%v err=%v", stale, err)
|
||||
}
|
||||
ticket2.Release()
|
||||
}
|
||||
|
||||
func TestAppendSequenceTrackerMidGapStillStale(t *testing.T) {
|
||||
tracker := newAppendSequenceTracker()
|
||||
ctx := context.Background()
|
||||
|
||||
for seq := int64(1); seq <= 3; seq++ {
|
||||
ticket, stale, err := tracker.Acquire(ctx, "req-gap", seq)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq %d: stale=%v err=%v", seq, stale, err)
|
||||
}
|
||||
ticket.Release()
|
||||
}
|
||||
|
||||
_, stale, err := tracker.Acquire(ctx, "req-gap", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("gap retransmit err=%v", err)
|
||||
}
|
||||
if !stale {
|
||||
t.Fatal("expected mid-sequence retransmit below next (but not 1) to stay stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSequenceTrackerWaitsForInOrder(t *testing.T) {
|
||||
tracker := newAppendSequenceTracker()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ticket1, stale, err := tracker.Acquire(ctx, "req-wait", 1)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq 1: stale=%v err=%v", stale, err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
ticket2, stale, err := tracker.Acquire(ctx, "req-wait", 2)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if stale {
|
||||
done <- errors.New("seq 2 unexpectedly stale")
|
||||
return
|
||||
}
|
||||
ticket2.Release()
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
ticket1.Release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("waiting seq 2 failed: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for ordered seq 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSequenceTrackerRestartWaitsUntilIdle(t *testing.T) {
|
||||
tracker := newAppendSequenceTracker()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Advance to next=3.
|
||||
for seq := int64(1); seq <= 2; seq++ {
|
||||
ticket, stale, err := tracker.Acquire(ctx, "req-idle", seq)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq %d: stale=%v err=%v", seq, stale, err)
|
||||
}
|
||||
ticket.Release()
|
||||
}
|
||||
|
||||
// Hold seq 3 so a concurrent restart must wait.
|
||||
hold, stale, err := tracker.Acquire(ctx, "req-idle", 3)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("seq 3 hold: stale=%v err=%v", stale, err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
ticket, stale, err := tracker.Acquire(ctx, "req-idle", 1)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
if stale {
|
||||
done <- errors.New("restart seq 1 unexpectedly stale")
|
||||
return
|
||||
}
|
||||
ticket.Release()
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
hold.Release()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("restart while busy failed: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timed out waiting for restart after idle")
|
||||
}
|
||||
|
||||
ticket2, stale, err := tracker.Acquire(ctx, "req-idle", 2)
|
||||
if err != nil || stale {
|
||||
t.Fatalf("post-restart seq 2: stale=%v err=%v", stale, err)
|
||||
}
|
||||
ticket2.Release()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user