mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c274a9db4c | ||
|
|
3cf8bdbc3c | ||
|
|
684953a80b | ||
|
|
297b56aed0 | ||
|
|
2393df1cb8 | ||
|
|
7d622dd039 | ||
|
|
1a7a20c519 | ||
|
|
9eb24bb4d4 | ||
|
|
426bdd6592 | ||
|
|
fa36dc2c60 | ||
|
|
4e649d676f | ||
|
|
917a711b42 | ||
|
|
270bcdb54e | ||
|
|
c6d719d9b9 | ||
|
|
a7f9494828 | ||
|
|
225de8bb85 | ||
|
|
5bd39ed317 | ||
|
|
26502ebec8 | ||
|
|
2e265d415e | ||
|
|
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 |
@@ -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 仍遵守独立过滤规则。
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,95 @@
|
||||
<div align="center">
|
||||
|
||||
# cursor-byok
|
||||
|
||||
[使用教程](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`
|
||||
|
||||
<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>
|
||||
|
||||
## 开发与贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [MIT License](./LICENSE) 开源。
|
||||
@@ -1,46 +1,95 @@
|
||||
<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
|
||||
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
[User Guide](https://docs.leokun.cn) · [Latest Release](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.
|
||||
|
||||
## 路线图
|
||||
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.
|
||||
|
||||
[正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)
|
||||
[详细使用教程](https://dcne38qm5vlg.feishu.cn/wiki/JeP7wdGnziBXuikNaF5czWbrn8c)
|
||||
> [!IMPORTANT]
|
||||
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
|
||||
|
||||
## 后续
|
||||
## Features
|
||||
|
||||
后续会继续扩展更多工具和使用场景,包括但不限于:
|
||||
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
|
||||
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
|
||||
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
|
||||
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
|
||||
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
|
||||
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
|
||||
- **Cross-platform:** Run on macOS, Windows, and Linux.
|
||||
|
||||
- 支持更多 IDE 接入
|
||||
- 支持更多 Chat 类应用
|
||||
- 支持更多 Agent 工具和工作流
|
||||
- 提供更完善的自托管部署方式
|
||||
- 持续优化不同模型 API 的兼容性
|
||||
- 降低接入成本,让已有模型额度可以被更充分地利用
|
||||
## Quick Start
|
||||
|
||||
最终希望做到:让你的模型 API 可以自由接入到你想使用的任何工具中。
|
||||
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.
|
||||
|
||||
## Star History
|
||||

|
||||
|
||||
<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>
|
||||
</a>
|
||||
## 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`
|
||||
|
||||
<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>
|
||||
|
||||
## Development and Contributing
|
||||
|
||||
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
|
||||
|
||||
## 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'
|
||||
|
||||
+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.41"
|
||||
version: "0.0.46"
|
||||
|
||||
dev_mode:
|
||||
root_path: .
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.41</string>
|
||||
<string>0.0.46</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.41</string>
|
||||
<string>0.0.46</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.41</string>
|
||||
<string>0.0.46</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.41</string>
|
||||
<string>0.0.46</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.41"
|
||||
version: "0.0.46"
|
||||
section: "default"
|
||||
priority: "extra"
|
||||
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "0.0.41"
|
||||
"file_version": "0.0.46"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "0.0.41",
|
||||
"ProductVersion": "0.0.46",
|
||||
"CompanyName": "Cursor助手",
|
||||
"FileDescription": "Cursor助手",
|
||||
"LegalCopyright": "© 2026, Cursor助手",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
!define INFO_PRODUCTNAME "Cursor助手"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "0.0.41"
|
||||
!define INFO_PRODUCTVERSION "0.0.46"
|
||||
!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.41" processorArchitecture="*"/>
|
||||
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" 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,447 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
)
|
||||
|
||||
type exchangeContext struct {
|
||||
id string
|
||||
}
|
||||
|
||||
// Server runs the HTTPS debugging proxy and its local web UI.
|
||||
type Server struct {
|
||||
config Config
|
||||
certManager *certs.Manager
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
proxyServer *http.Server
|
||||
uiServer *http.Server
|
||||
proxyLn net.Listener
|
||||
uiLn net.Listener
|
||||
runMu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a standalone Cursor protocol debugger.
|
||||
func New(config Config) (*Server, error) {
|
||||
config = config.normalized()
|
||||
if err := validateLoopbackAddress(config.UIAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager, err := certs.NewEmbeddedManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 MITM CA 失败:%w", err)
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
certManager: manager,
|
||||
store: newExchangeStore(config.MaxExchanges),
|
||||
}
|
||||
proxyHandler, err := server.newProxyHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.proxyServer = &http.Server{
|
||||
Handler: proxyHandler,
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
server.uiServer = &http.Server{Handler: server.newUIHandler()}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start starts both listeners without modifying Cursor or system proxy settings.
|
||||
func (server *Server) Start() error {
|
||||
server.runMu.Lock()
|
||||
defer server.runMu.Unlock()
|
||||
if server.proxyLn != nil || server.uiLn != nil {
|
||||
return errors.New("调试代理已经启动")
|
||||
}
|
||||
proxyListener, err := net.Listen("tcp", server.config.ProxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动代理监听失败:%w", err)
|
||||
}
|
||||
uiListener, err := net.Listen("tcp", server.config.UIAddr)
|
||||
if err != nil {
|
||||
_ = proxyListener.Close()
|
||||
return fmt.Errorf("启动调试界面失败:%w", err)
|
||||
}
|
||||
server.proxyLn = proxyListener
|
||||
server.uiLn = uiListener
|
||||
go func() { _ = server.proxyServer.Serve(proxyListener) }()
|
||||
go func() { _ = server.uiServer.Serve(uiListener) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close stops both listeners.
|
||||
func (server *Server) Close(ctx context.Context) error {
|
||||
server.runMu.Lock()
|
||||
proxyServer := server.proxyServer
|
||||
uiServer := server.uiServer
|
||||
server.proxyLn = nil
|
||||
server.uiLn = nil
|
||||
server.runMu.Unlock()
|
||||
var errorsList []error
|
||||
if proxyServer != nil {
|
||||
if err := proxyServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
if uiServer != nil {
|
||||
if err := uiServer.Shutdown(ctx); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errorsList...)
|
||||
}
|
||||
|
||||
func (server *Server) ProxyAddr() string { return server.config.ProxyAddr }
|
||||
func (server *Server) UIAddr() string { return server.config.UIAddr }
|
||||
func (server *Server) UIURL() string { return "http://" + browserAddress(server.config.UIAddr) }
|
||||
|
||||
func (server *Server) newProxyHandler() (*goproxy.ProxyHttpServer, error) {
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
proxy.Verbose = false
|
||||
proxy.AllowHTTP2 = true
|
||||
proxy.Logger = log.New(io.Discard, "", 0)
|
||||
proxy.ConnectionErrHandler = func(_ io.Writer, context *goproxy.ProxyCtx, connectionErr error) {
|
||||
id := exchangeID(context)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = connectionErr.Error()
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
})
|
||||
}
|
||||
proxy.Tr = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 200,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
caCertificate, err := server.certManager.CATLSCertificate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 MITM CA 失败:%w", err)
|
||||
}
|
||||
baseTLSConfig := goproxy.TLSConfigFromCA(caCertificate)
|
||||
mitmAction := &goproxy.ConnectAction{
|
||||
Action: goproxy.ConnectMitm,
|
||||
TLSConfig: func(host string, context *goproxy.ProxyCtx) (*tls.Config, error) {
|
||||
return baseTLSConfig(host, context)
|
||||
},
|
||||
}
|
||||
proxy.OnRequest().HandleConnectFunc(func(host string, _ *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||
if server.matchesTargetHost(host) {
|
||||
return mitmAction, host
|
||||
}
|
||||
return goproxy.OkConnect, host
|
||||
})
|
||||
|
||||
proxy.OnRequest().DoFunc(server.captureRequest)
|
||||
proxy.OnResponse().DoFunc(server.captureResponse)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureRequest(request *http.Request, context *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
if request == nil || request.Method == http.MethodConnect || !server.matchesHTTPRequest(request) {
|
||||
return request, nil
|
||||
}
|
||||
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||
path := request.URL.Path
|
||||
requestCodec := requestContentCodec(path, request.Header)
|
||||
exchange := &Exchange{
|
||||
ExchangeSummary: ExchangeSummary{
|
||||
ID: id,
|
||||
StartedAt: time.Now(),
|
||||
Method: request.Method,
|
||||
URL: request.URL.String(),
|
||||
Host: request.URL.Host,
|
||||
Path: path,
|
||||
State: "pending",
|
||||
},
|
||||
Request: Payload{
|
||||
Headers: sortedHeaders(request.Header),
|
||||
ContentType: request.Header.Get("Content-Type"),
|
||||
ContentCodec: requestCodec,
|
||||
Frames: make([]FrameView, 0),
|
||||
},
|
||||
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||
}
|
||||
server.store.create(exchange)
|
||||
context.UserData = exchangeContext{id: id}
|
||||
|
||||
if request.Body == nil {
|
||||
server.finishRequestBody(id, path, requestCodec, nil, 0, false, nil)
|
||||
return request, nil
|
||||
}
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"aiserver.v1.BidiRequestId",
|
||||
requestCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendRequestFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
request.Body = newCaptureReadCloser(
|
||||
request.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishRequestBody(id, path, requestCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (server *Server) captureResponse(response *http.Response, context *goproxy.ProxyCtx) *http.Response {
|
||||
id := exchangeID(context)
|
||||
if id == "" || response == nil {
|
||||
return response
|
||||
}
|
||||
path := ""
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
path = response.Request.URL.Path
|
||||
}
|
||||
responseCodec := responseContentCodec(path, response.Header)
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.Status = response.StatusCode
|
||||
exchange.State = "streaming"
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.Response.Headers = sortedHeaders(response.Header)
|
||||
exchange.Response.ContentType = response.Header.Get("Content-Type")
|
||||
exchange.Response.ContentCodec = responseCodec
|
||||
})
|
||||
if response.Body == nil {
|
||||
server.finishResponseBody(id, path, responseCodec, nil, 0, false, nil)
|
||||
return response
|
||||
}
|
||||
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
"agent.v1.AgentServerMessage",
|
||||
responseCodec,
|
||||
server.config.MaxFrames,
|
||||
func(frame FrameView) { server.appendResponseFrame(id, frame) },
|
||||
)
|
||||
}
|
||||
response.Body = newCaptureReadCloser(
|
||||
response.Body,
|
||||
server.config.MaxCaptureBytes,
|
||||
func(chunk []byte) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Write(chunk)
|
||||
}
|
||||
},
|
||||
func(captured []byte, size int64, truncated bool, readErr error) {
|
||||
if frameDecoder != nil {
|
||||
frameDecoder.Close()
|
||||
}
|
||||
server.finishResponseBody(id, path, responseCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
func (server *Server) finishRequestBody(id, path string, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryRequest(path) && truncated {
|
||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryRequest(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, requestID, decodeErr := "", "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, requestID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.RequestBytes = size
|
||||
exchange.Request.Size = size
|
||||
exchange.Request.RawHex = rawHex(captured)
|
||||
exchange.Request.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Request.DecodedJSON = decodedJSON
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.RequestKind = kind
|
||||
}
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Request.DecodeError = decodeErr.Error()
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requestContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func responseContentCodec(path string, headers http.Header) string {
|
||||
if path == "/agent.v1.AgentService/RunSSE" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
if !decodesUnaryResponse(path) {
|
||||
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
|
||||
return codec
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func (server *Server) finishResponseBody(id, path, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
if decodesUnaryResponse(path) && truncated {
|
||||
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||
} else if decodesUnaryResponse(path) && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, kind, decodeErr := "", "", contentDecodeErr
|
||||
if decodeErr == nil {
|
||||
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.ResponseBytes = size
|
||||
exchange.Response.Size = size
|
||||
exchange.Response.RawHex = rawHex(captured)
|
||||
exchange.Response.RawTruncated = truncated
|
||||
if decodedJSON != "" {
|
||||
exchange.Response.DecodedJSON = decodedJSON
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.ResponseKind = kind
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Response.DecodeError = decodeErr.Error()
|
||||
}
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
exchange.State = "completed"
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
if len(exchange.Request.Frames) < server.config.MaxFrames {
|
||||
exchange.Request.Frames = append(exchange.Request.Frames, frame)
|
||||
}
|
||||
if frame.Kind != "" {
|
||||
exchange.RequestKind = frame.Kind
|
||||
}
|
||||
if frame.RequestID != "" {
|
||||
exchange.RequestID = frame.RequestID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
if len(exchange.Response.Frames) < server.config.MaxFrames {
|
||||
exchange.Response.Frames = append(exchange.Response.Frames, frame)
|
||||
}
|
||||
exchange.FrameCount = len(exchange.Response.Frames)
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.ResponseKind = frame.Kind
|
||||
}
|
||||
if frame.Error != "" {
|
||||
exchange.Response.DecodeError = frame.Error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) matchesHTTPRequest(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
host := request.Host
|
||||
if request.URL != nil && request.URL.Host != "" {
|
||||
host = request.URL.Host
|
||||
}
|
||||
return server.matchesTargetHost(host)
|
||||
}
|
||||
|
||||
func (server *Server) matchesTargetHost(host string) bool {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
target := strings.TrimSpace(strings.ToLower(server.config.TargetHost))
|
||||
return host == target
|
||||
}
|
||||
|
||||
func exchangeID(context *goproxy.ProxyCtx) string {
|
||||
if context == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := context.UserData.(exchangeContext)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return value.id
|
||||
}
|
||||
|
||||
func browserAddress(address string) string {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return address
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func validateLoopbackAddress(address string) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("调试界面监听地址无效:%w", err)
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return errors.New("调试界面只能监听本机回环地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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,112 @@
|
||||
package proxydebugger
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cursor/internal/certs"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webAssets embed.FS
|
||||
|
||||
func (server *Server) newUIHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/status", server.handleStatus)
|
||||
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
|
||||
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
|
||||
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
|
||||
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
|
||||
assets, _ := fs.Sub(webAssets, "web")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/", fileServer)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]any{
|
||||
"proxyAddr": server.config.ProxyAddr,
|
||||
"uiAddr": server.config.UIAddr,
|
||||
"targetHost": server.config.TargetHost,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, server.store.summaries())
|
||||
}
|
||||
|
||||
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||
id := strings.TrimSpace(request.PathValue("id"))
|
||||
exchange, ok := server.store.get(id)
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, exchange)
|
||||
}
|
||||
|
||||
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||
server.store.clear()
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := writer.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(writer, "当前响应不支持流式刷新", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
writer.Header().Set("Cache-Control", "no-cache")
|
||||
writer.Header().Set("Connection", "keep-alive")
|
||||
updates, unsubscribe := server.store.subscribe()
|
||||
defer unsubscribe()
|
||||
fmt.Fprint(writer, "event: ready\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case event, open := <-updates:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(event)
|
||||
fmt.Fprintf(writer, "event: update\ndata: %s\n\n", payload)
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(writer, ": heartbeat\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
|
||||
_, _ = writer.Write(certs.EmbeddedCACertPEM())
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, payload any) {
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
writer.Header().Set("Referrer-Policy", "no-referrer")
|
||||
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,6 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => createEmptyModelAdapter(),
|
||||
},
|
||||
errorMessage: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["cancel", "save"]);
|
||||
@@ -286,13 +285,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,6 +13,7 @@ import {
|
||||
createEmptyModelAdapter,
|
||||
CUSTOM_HEADERS_DEFAULT_JSON,
|
||||
EXTRA_PARAMS_DEFAULT_JSON,
|
||||
fetchAvailableModelIDs,
|
||||
getModelAdapterTestResult,
|
||||
getModelAdapterTestResultByID,
|
||||
isModelAdapterTestResultStale,
|
||||
@@ -25,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]" },
|
||||
@@ -55,12 +56,25 @@ const openAIEndpointOptions = [
|
||||
{ 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({
|
||||
@@ -80,14 +94,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(() => {
|
||||
@@ -97,8 +120,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;
|
||||
@@ -125,7 +146,7 @@ function ensureAnthropicThinkingEffort() {
|
||||
|
||||
const fieldTips = {
|
||||
displayName: "仅用于界面展示,便于你区分不同模型。",
|
||||
modelID: "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
modelID: "可以直接输入模型标识,或从服务端返回的列表中选择。",
|
||||
baseURL: "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
apiKey: "调用该模型服务需要使用的访问密钥。",
|
||||
contextWindowTokens: "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
@@ -140,20 +161,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +205,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 };
|
||||
}
|
||||
|
||||
@@ -177,12 +220,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,15 +235,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") {
|
||||
@@ -273,31 +322,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
|
||||
@@ -318,26 +376,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>
|
||||
@@ -358,17 +403,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" />
|
||||
@@ -533,19 +604,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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"02216368edc68816": "No release notes",
|
||||
"02bc2e95bf49e587": "No",
|
||||
"03b11112dc970014": "Base URL",
|
||||
"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",
|
||||
"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",
|
||||
@@ -20,7 +16,7 @@
|
||||
"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",
|
||||
"1afed6a81a2512d2": "Select model",
|
||||
"1baddde657dd2720": "Current outbound requests use system proxy",
|
||||
"1bc77f5ab979f4c1": "Add Model Settings",
|
||||
"1e238093b79b3165": "Uses 65536 by default when left blank",
|
||||
@@ -30,7 +26,6 @@
|
||||
"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.",
|
||||
"2a24519398684ed5": "Visit Homepage",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d706f7981b45a7b": "Local settings saved",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
@@ -41,13 +36,10 @@
|
||||
"3463c5585c246df9": "Total: {0}",
|
||||
"3468b57e3edbc599": "Aggregated from turn summaries scanned from the history.",
|
||||
"35076178fe79a210": "Configuration changed. Please test again.",
|
||||
"358f07b2c1445ab1": "Author's Message",
|
||||
"36c149a9b3e8dca0": "models configured yet.",
|
||||
"37d23612f78a2e63": "Restart Now to Update",
|
||||
"392d0dceb45998d3": "Extreme",
|
||||
"393df9bb13ea4900": "Hit",
|
||||
"3af7e5489e61ea51": "Refreshing",
|
||||
"3bf8512aa520ed21": "Local Service Mode",
|
||||
"3c2a9f9901109e75": "{0} type only supports OpenAI or Anthropic",
|
||||
"3d13868593ae4eeb": "Interface Language",
|
||||
"3ea83f9f55062582": "Release date: {0}",
|
||||
@@ -60,17 +52,19 @@
|
||||
"472642d58d3d5a6d": "Formula: {0}",
|
||||
"4923eeb7bd75cccd": "{0} model ID cannot be empty",
|
||||
"497c85690c4cc0fc": "No data",
|
||||
"4b5e0ae1288a9695": "No matching models",
|
||||
"4c0a929bb86ce912": "Current: {0}",
|
||||
"4d2b6e53be6002e5": "Cache Statistics Strategy: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Unknown",
|
||||
"4f0982ba1d37e51b": "Current outbound requests use environment variable proxy",
|
||||
"51194c3ad014fb29": "Retest required",
|
||||
"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",
|
||||
"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",
|
||||
@@ -84,7 +78,6 @@
|
||||
"66af574b8948fe83": "{0} API key cannot be empty",
|
||||
"6744b4c6a9aa0038": "Disabled",
|
||||
"675109292da4eb36": "Not tested yet",
|
||||
"699fe7ade5407687": "Direct Mode",
|
||||
"6a7b96f399e58138": "e.g. sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "Test",
|
||||
"6ae23d6d7cb18592": "Service error",
|
||||
@@ -92,6 +85,7 @@
|
||||
"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",
|
||||
@@ -99,23 +93,22 @@
|
||||
"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",
|
||||
"83fcfb4c1f2c1641": "Fetch Models",
|
||||
"8672864e90417138": "Max",
|
||||
"86df7ec743047234": "Service running",
|
||||
"87ed126f7bd1121e": "Routing Mode",
|
||||
"899add6275682210": "Uses 200000 by default when left blank",
|
||||
"8a4ef3e48e4e8a5a": "Enabled",
|
||||
"8c0d84831a3c3d5b": "Currently in Local Service Mode",
|
||||
"8c1935935600e336": "Model Test",
|
||||
"8cbcf741e727dbf7": "Model Settings",
|
||||
"8d1de152be6360ce": "Valid ratio: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "e.g. OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Copied",
|
||||
"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",
|
||||
@@ -123,9 +116,7 @@
|
||||
"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",
|
||||
"93faf55cd25c8319": "This software is completely free. If you were charged, you were likely scammed.\nWelcome to visit the author's homepage at https://space.bilibili.com/311706663/upload/video\nto see more updates, sharing guides, and future content.",
|
||||
"942ff2d88baca0c6": "Checking for updates...",
|
||||
"970388573a3c88c9": "Cache Read: {0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "Max",
|
||||
@@ -133,11 +124,9 @@
|
||||
"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",
|
||||
"9c41b3a9e12ac994": "Thinking Effort",
|
||||
"9d2ca261281a158a": "Later",
|
||||
"9d2fb46c0ba890b9": "Custom Path",
|
||||
"9dc0825fba5422e4": "Loading...",
|
||||
"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",
|
||||
@@ -146,24 +135,23 @@
|
||||
"a325d25c69e7256d": "Model settings not found; cannot duplicate",
|
||||
"a4dd8bb7e8b6eb31": "Show API Key",
|
||||
"a54d745d9a9249e2": "When enabled, the JSON object will override the Anthropic request body. Duplicate fields are determined by this field.",
|
||||
"a55a88237df85d98": "Currently in Direct Mode",
|
||||
"a567bdaa11367f26": "Medium",
|
||||
"a5f1bd344c92e195": "The API key required to call this model service.",
|
||||
"a693d69af48bfe48": "Save and Test",
|
||||
"a98585871c5313ff": "Display Name",
|
||||
"aa9e366f68d3d097": "Low",
|
||||
"ab607d54d86dc789": "Author leookun",
|
||||
"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",
|
||||
@@ -174,19 +162,17 @@
|
||||
"bddd504af0c92fd0": "System PAC/automatic proxy detected; current version is handled as a direct connection",
|
||||
"bef280f9eb392495": "Conversation Turns",
|
||||
"c228558cf257fc49": "Delete failed",
|
||||
"c3e9c3c60020b8b7": "Select Mode",
|
||||
"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",
|
||||
"c8c14507b2d37395": "Reasoning Effort",
|
||||
"c98e118e0a43f078": "Model",
|
||||
"c9dd59beefd7144f": "Cache Read / (Cache Read + Non-cache Input)",
|
||||
"ca00a39fcea70dc6": "Starting...",
|
||||
"ca1d1059408b3837": "Invalid turns: {0}",
|
||||
"cc5049729a2c10f1": "Test failed. Check the raw details.",
|
||||
"cd7ca5fb221e1c53": "{0} cannot be empty",
|
||||
"ce46f23cea3bf3c5": "When enabled, Cursor connects directly to the official service. Do not enable this.",
|
||||
"d0325067fed88e5a": "Cache hit rate {0}",
|
||||
"d08fd4224abcd69d": "Switch failed",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] Failed to load author info",
|
||||
"d20ab96566d33f25": "{0} display name cannot be empty",
|
||||
"d2243e1d44b2a94e": "Edit Model Settings",
|
||||
"d3209b935ae86797": "Model settings not found; cannot delete",
|
||||
@@ -212,12 +198,15 @@
|
||||
"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",
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"02216368edc68816": "更新内容はありません",
|
||||
"02bc2e95bf49e587": "まだ",
|
||||
"03b11112dc970014": "ベース URL",
|
||||
"047ec6b71d0cec08": "ホワイトリストのメイン経路のリクエストをローカルサービス経由にするか、元の Cursor 上流アドレスに戻すかを制御します",
|
||||
"04f632dd4f034d5e": "{0} のコンテキストウィンドウは正の整数である必要があります",
|
||||
"051836569928a9f9": "編集",
|
||||
"054d763265603305": "例: 65536(空欄でデフォルト値)",
|
||||
"05c8a9238c702efa": "Cursor 直結モード",
|
||||
"0647728439b5da2e": "ルーティングモードとモデルチャネルを設定できます。実行ログは次にあります",
|
||||
"092b520558eff5f2": "未テスト",
|
||||
"09ebc2643631ba25": "価値見積もり",
|
||||
"0b0e7478e41fe677": "{0} のツールチップは必須です",
|
||||
"0c3b4cf7aa259edb": "操作に失敗しました",
|
||||
"0dde813d719dbd01": "ホームページを開けませんでした",
|
||||
"0d6b7efd5ccefd8a": "モデルチャネルを設定できます。実行ログは次にあります",
|
||||
"1117a2f86030d03b": "キャッシュの読み書きは Prompt 側の統計に含まれます。",
|
||||
"11afd2a534395b18": "有効",
|
||||
"124be3f86f197802": "Token 使用量",
|
||||
@@ -20,7 +16,7 @@
|
||||
"15d124b200ddabed": "モデルが1回のリクエストで受け取れる最大コンテキスト Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"185aebe19c77425d": "{0}はJSONオブジェクトである必要があります",
|
||||
"18b7312022cd1840": "サービスを開始",
|
||||
"1af38868896cf53d": "ルーティングモードは local または upstream のみサポートします",
|
||||
"1afed6a81a2512d2": "モデルを選択",
|
||||
"1baddde657dd2720": "現在のアウトバウンドリクエストはシステムプロキシを使用しています",
|
||||
"1bc77f5ab979f4c1": "モデル設定を追加",
|
||||
"1e238093b79b3165": "空欄で 65536",
|
||||
@@ -30,7 +26,6 @@
|
||||
"26a3855aed1d8d17": "サービスは起動していません",
|
||||
"281eb6d08c9960d0": "{0} の思考予算 Token は正の整数である必要があります",
|
||||
"28aeffc70ceb4267": "この画面の表示言語を切り替えます。設定はすぐに反映され、この端末に保存されます",
|
||||
"2a24519398684ed5": "ホームページへ",
|
||||
"2cd0f3be8738a86c": "キャンセル",
|
||||
"2d706f7981b45a7b": "ローカル設定を保存しました",
|
||||
"2f9daa828907b93f": "削除",
|
||||
@@ -41,13 +36,10 @@
|
||||
"3463c5585c246df9": "合計:{0}",
|
||||
"3468b57e3edbc599": "履歴からスキャンした各ターンの summary を集計しています。",
|
||||
"35076178fe79a210": "設定が変更されました。再テストしてください",
|
||||
"358f07b2c1445ab1": "著者からのメッセージ",
|
||||
"36c149a9b3e8dca0": "モデルは設定されていません。",
|
||||
"37d23612f78a2e63": "今すぐ再起動して更新",
|
||||
"392d0dceb45998d3": "最高",
|
||||
"393df9bb13ea4900": "ヒット",
|
||||
"3af7e5489e61ea51": "更新中",
|
||||
"3bf8512aa520ed21": "ローカルサービスモード",
|
||||
"3c2a9f9901109e75": "{0} のタイプは OpenAI または Anthropic のみサポートします",
|
||||
"3d13868593ae4eeb": "表示言語",
|
||||
"3ea83f9f55062582": "公開日時: {0}",
|
||||
@@ -60,17 +52,19 @@
|
||||
"472642d58d3d5a6d": "数式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} のモデル ID は必須です",
|
||||
"497c85690c4cc0fc": "データなし",
|
||||
"4b5e0ae1288a9695": "一致するモデルがありません",
|
||||
"4c0a929bb86ce912": "現在:{0}",
|
||||
"4d2b6e53be6002e5": "キャッシュ統計ポリシー:{0}({1})",
|
||||
"4d8c1c5b42830791": "不明",
|
||||
"4f0982ba1d37e51b": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています",
|
||||
"51194c3ad014fb29": "再テストが必要",
|
||||
"5205125c0e91d346": "Anthropic モデルが1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"54e6745ff43c9c74": "並べ替えに失敗しました",
|
||||
"56627c94a9decee6": "最大出力 Token",
|
||||
"593a972852ba0004": "Cursor アシスタント | 永久無料 | カスタム API",
|
||||
"59a2195a01a8b35b": "{0}は有効なJSONオブジェクトである必要があります",
|
||||
"5aa8f5590c940829": "非キャッシュ入力:{0}",
|
||||
"5beb1206c532729f": "1回の応答で生成できる最大 Token 数。空欄の場合はデフォルト値を使用します。",
|
||||
"5c55a67935af8f45": "すべて",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"5e709712ce012f5d": "現在のアウトバウンドリクエストは環境変数プロキシを使用しています:{0}",
|
||||
"6106f0a12583a334": "再読み込みに失敗しました",
|
||||
@@ -84,7 +78,6 @@
|
||||
"66af574b8948fe83": "{0} の API キーは必須です",
|
||||
"6744b4c6a9aa0038": "無効化",
|
||||
"675109292da4eb36": "まだテストしていません",
|
||||
"699fe7ade5407687": "直結モード",
|
||||
"6a7b96f399e58138": "例: sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "テスト",
|
||||
"6ae23d6d7cb18592": "サービスエラー",
|
||||
@@ -92,30 +85,30 @@
|
||||
"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": "更新しました",
|
||||
"83fcfb4c1f2c1641": "モデルを取得",
|
||||
"8672864e90417138": "最大",
|
||||
"86df7ec743047234": "サービス稼働中",
|
||||
"87ed126f7bd1121e": "ルーティングモード",
|
||||
"899add6275682210": "空欄で 200000",
|
||||
"8a4ef3e48e4e8a5a": "有効",
|
||||
"8c0d84831a3c3d5b": "現在はローカルサービスモードです",
|
||||
"8c1935935600e336": "モデルテスト",
|
||||
"8cbcf741e727dbf7": "モデル設定",
|
||||
"8d1de152be6360ce": "有効率: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "例: OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "コピーしました",
|
||||
"8f8baf5d18dd0492": "有効にすると、JSONオブジェクトがOpenAIのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。OpenAIのservice_tierはauto、default、flex、scale、priorityをサポートしており、priorityは高優先度/Fastのシナリオで使用できます。",
|
||||
"8faa670b512b6b9b": "モデル設定を開く",
|
||||
@@ -123,9 +116,7 @@
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "すべてテスト",
|
||||
"91cba5c107a51892": "/ 異常",
|
||||
"92059fe6cd713db4": "実際にサーバーへ送信されるモデル名です。例: gpt-4.1 または claude-sonnet。",
|
||||
"93e08803675e378b": "モデルID",
|
||||
"93faf55cd25c8319": "このソフトウェアは完全に無料です。もし料金を請求された場合は、詐欺の可能性が高いです。\n著者のホームページ https://space.bilibili.com/311706663/upload/video にアクセスして、更新情報や利用方法などを確認してください。",
|
||||
"942ff2d88baca0c6": "アップデートを確認中...",
|
||||
"970388573a3c88c9": "キャッシュ読み取り:{0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "最大",
|
||||
@@ -133,11 +124,9 @@
|
||||
"9970736b36ff2b68": "モデルサービスの API ルート URL。通常は OpenAI または Anthropic 互換のエンドポイントです。",
|
||||
"9a6e7d6c17471711": "現在、デフォルトのヒット率定義に従って表示されています",
|
||||
"9b17fa889b307f7f": "有効ターン: {0}",
|
||||
"9c38b6e9bf94abec": "Cursor 直結モードに切り替えました",
|
||||
"9c41b3a9e12ac994": "思考強度",
|
||||
"9d2ca261281a158a": "後で",
|
||||
"9d2fb46c0ba890b9": "カスタムパス",
|
||||
"9dc0825fba5422e4": "読み込み中...",
|
||||
"9e02529bcaef36c6": "モデルチャネルが重複しています。url、modelID、apiKey、displayName、endpointの組み合わせを確認してください",
|
||||
"a026f37e613cf48b": "出力 Token",
|
||||
"a0d36236c523667c": "{0} のAnthropic思考強度はlow、medium、high、xhigh、maxのみをサポートしています",
|
||||
@@ -146,24 +135,23 @@
|
||||
"a325d25c69e7256d": "モデル設定が存在しないため複製できません",
|
||||
"a4dd8bb7e8b6eb31": "API キーを表示",
|
||||
"a54d745d9a9249e2": "有効にすると、JSONオブジェクトがAnthropicのリクエストボディを上書きします。同名のフィールドはこの設定が優先されます。",
|
||||
"a55a88237df85d98": "現在は直結モードです",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "このモデルサービスを呼び出すために必要な API キーです。",
|
||||
"a693d69af48bfe48": "保存してテスト",
|
||||
"a98585871c5313ff": "表示名",
|
||||
"aa9e366f68d3d097": "低",
|
||||
"ab607d54d86dc789": "著者 leookun",
|
||||
"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": "システムプロキシを認識しました",
|
||||
@@ -174,19 +162,17 @@
|
||||
"bddd504af0c92fd0": "システムのPAC/自動プロキシが検出されました。現在のバージョンは直接接続として処理されます",
|
||||
"bef280f9eb392495": "会話ターン",
|
||||
"c228558cf257fc49": "削除に失敗しました",
|
||||
"c3e9c3c60020b8b7": "モードを選択",
|
||||
"c5af02060847d167": "Anthropic adaptive thinkingの思考強度。リクエストは一貫して新しいthinking.type=adaptiveを使用します。",
|
||||
"c6868592796ac2b2": "まだ {0} モデルが設定されていません。",
|
||||
"c69f5bce63b9f14c": "設定フォルダー",
|
||||
"c8c14507b2d37395": "推論強度",
|
||||
"c98e118e0a43f078": "モデル",
|
||||
"c9dd59beefd7144f": "キャッシュ読み取り / (キャッシュ読み取り + 非キャッシュ入力)",
|
||||
"ca00a39fcea70dc6": "起動中...",
|
||||
"ca1d1059408b3837": "異常ターン: {0}",
|
||||
"cc5049729a2c10f1": "テストに失敗しました。元の詳細情報を確認してください。",
|
||||
"cd7ca5fb221e1c53": "{0}は空にできません",
|
||||
"ce46f23cea3bf3c5": "有効にすると、Cursor は公式サービスへ直接接続します。オンにしないでください",
|
||||
"d0325067fed88e5a": "キャッシュヒット率 {0}",
|
||||
"d08fd4224abcd69d": "切替に失敗しました",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 作者情報の読み込みに失敗しました",
|
||||
"d20ab96566d33f25": "{0} の表示名は必須です",
|
||||
"d2243e1d44b2a94e": "モデル設定を編集",
|
||||
"d3209b935ae86797": "モデル設定が存在しないため削除できません",
|
||||
@@ -212,12 +198,15 @@
|
||||
"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": "現在のアウトバウンドリクエストはシステムプロキシを使用していません",
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"02216368edc68816": "Нет примечаний к выпуску",
|
||||
"02bc2e95bf49e587": "Пока нет настроенных",
|
||||
"03b11112dc970014": "Базовый URL",
|
||||
"047ec6b71d0cec08": "Определяет, будут ли запросы основного маршрута из белого списка проходить через локальный сервис или исходный сервер Cursor",
|
||||
"04f632dd4f034d5e": "Размер контекстного окна {0} должен быть положительным целым числом",
|
||||
"051836569928a9f9": "Изменить",
|
||||
"054d763265603305": "например, 65536 (оставьте пустым для значения по умолчанию)",
|
||||
"05c8a9238c702efa": "Прямое подключение к Cursor",
|
||||
"0647728439b5da2e": "Здесь можно настроить режим маршрутизации и каналы моделей. Журналы выполнения находятся в",
|
||||
"092b520558eff5f2": "Не проверено",
|
||||
"09ebc2643631ba25": "Оценка стоимости",
|
||||
"0b0e7478e41fe677": "Текст подсказки {0} не может быть пустым",
|
||||
"0c3b4cf7aa259edb": "Не удалось выполнить операцию",
|
||||
"0dde813d719dbd01": "Не удалось открыть домашнюю страницу",
|
||||
"0d6b7efd5ccefd8a": "Здесь можно настроить каналы моделей. Журналы выполнения находятся в",
|
||||
"1117a2f86030d03b": "Чтение и запись кеша включены в статистику Prompt.",
|
||||
"11afd2a534395b18": "Успешно",
|
||||
"124be3f86f197802": "Использование токенов",
|
||||
@@ -20,7 +16,7 @@
|
||||
"15d124b200ddabed": "Максимальное число токенов контекста, которое модель может принять за один запрос. Оставьте поле пустым для значения по умолчанию.",
|
||||
"185aebe19c77425d": "{0} должен быть объектом JSON",
|
||||
"18b7312022cd1840": "Запустить сервис",
|
||||
"1af38868896cf53d": "Режим маршрутизации поддерживает только local или upstream",
|
||||
"1afed6a81a2512d2": "Выберите модель",
|
||||
"1baddde657dd2720": "Исходящие запросы используют системный прокси",
|
||||
"1bc77f5ab979f4c1": "Добавить настройки модели",
|
||||
"1e238093b79b3165": "Если оставить пустым, используется 65536",
|
||||
@@ -30,7 +26,6 @@
|
||||
"26a3855aed1d8d17": "Сервис не запущен",
|
||||
"281eb6d08c9960d0": "Бюджет токенов рассуждения {0} должен быть положительным целым числом",
|
||||
"28aeffc70ceb4267": "Измените язык интерфейса. Настройка применяется сразу и сохраняется на этом устройстве.",
|
||||
"2a24519398684ed5": "Перейти на домашнюю страницу",
|
||||
"2cd0f3be8738a86c": "Отмена",
|
||||
"2d706f7981b45a7b": "Локальные настройки сохранены",
|
||||
"2f9daa828907b93f": "Удалить",
|
||||
@@ -41,13 +36,10 @@
|
||||
"3463c5585c246df9": "Итого: {0}",
|
||||
"3468b57e3edbc599": "Сводка составлена по данным ходов, найденным в истории.",
|
||||
"35076178fe79a210": "Конфигурация изменена. Выполните проверку снова.",
|
||||
"358f07b2c1445ab1": "Сообщение автора",
|
||||
"36c149a9b3e8dca0": "моделей.",
|
||||
"37d23612f78a2e63": "Перезапустить и обновить",
|
||||
"392d0dceb45998d3": "Очень высокая",
|
||||
"393df9bb13ea4900": "Попадание",
|
||||
"3af7e5489e61ea51": "Обновление",
|
||||
"3bf8512aa520ed21": "Режим локального сервиса",
|
||||
"3c2a9f9901109e75": "Тип {0} поддерживает только OpenAI или Anthropic",
|
||||
"3d13868593ae4eeb": "Язык интерфейса",
|
||||
"3ea83f9f55062582": "Дата выпуска: {0}",
|
||||
@@ -60,17 +52,19 @@
|
||||
"472642d58d3d5a6d": "Формула: {0}",
|
||||
"4923eeb7bd75cccd": "Идентификатор модели {0} не может быть пустым",
|
||||
"497c85690c4cc0fc": "Нет данных",
|
||||
"4b5e0ae1288a9695": "Подходящих моделей нет",
|
||||
"4c0a929bb86ce912": "Сейчас: {0}",
|
||||
"4d2b6e53be6002e5": "Стратегия статистики кеша: {0} ({1})",
|
||||
"4d8c1c5b42830791": "Неизвестно",
|
||||
"4f0982ba1d37e51b": "Исходящие запросы используют прокси из переменных окружения",
|
||||
"51194c3ad014fb29": "Требуется повторная проверка",
|
||||
"5205125c0e91d346": "Максимальное число токенов, которое модель Anthropic может сгенерировать за один ответ. Оставьте поле пустым для значения по умолчанию.",
|
||||
"54e6745ff43c9c74": "Не удалось изменить порядок",
|
||||
"56627c94a9decee6": "Макс. выходных токенов",
|
||||
"593a972852ba0004": "Cursor Assistant | Всегда бесплатно | Пользовательский API",
|
||||
"59a2195a01a8b35b": "{0} должен быть допустимым объектом JSON",
|
||||
"5aa8f5590c940829": "Ввод без кеша: {0}",
|
||||
"5beb1206c532729f": "Максимальное число токенов в одном ответе. Оставьте поле пустым для значения по умолчанию.",
|
||||
"5c55a67935af8f45": "Все",
|
||||
"5d1687a4a41883fd": "Остановка...",
|
||||
"5e709712ce012f5d": "Исходящие запросы используют прокси из переменных окружения: {0}",
|
||||
"6106f0a12583a334": "Не удалось обновить",
|
||||
@@ -84,7 +78,6 @@
|
||||
"66af574b8948fe83": "Ключ API {0} не может быть пустым",
|
||||
"6744b4c6a9aa0038": "Выключено",
|
||||
"675109292da4eb36": "Еще не проверено",
|
||||
"699fe7ade5407687": "Прямой режим",
|
||||
"6a7b96f399e58138": "например, sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "Проверить",
|
||||
"6ae23d6d7cb18592": "Ошибка сервиса",
|
||||
@@ -92,30 +85,30 @@
|
||||
"6e584e3d5ce64aa0": "Сохранить настройки",
|
||||
"6ec87609a8769425": "По умолчанию {0} / С учетом создания {1}",
|
||||
"72f6c3525c0192a7": "Если включено, создание кеша учитывается в знаменателе",
|
||||
"736c9dc2a04c65fd": "Конфигурация моделей изменилась. Обновите данные и повторите попытку.",
|
||||
"737225e2904673fc": "Расчетные выходные токены: {0}",
|
||||
"7520bd50a5ee5471": "Остановить проверку {0}/{1}",
|
||||
"753d8bb0da9913ce": "Не удалось дублировать",
|
||||
"774d6e1b7cb89751": "Стандартный расчет",
|
||||
"77c9e582e85583af": "Проверка не пройдена",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе и помогает различать модели.",
|
||||
"7a26bf794e9fb6bf": "Используется только для отображения в интерфейсе, чтобы различать модели.",
|
||||
"7b6187c41e88b70c": "Проверка...",
|
||||
"7bf8e2c07e084d09": "Редактор модели",
|
||||
"7df7641e5e741346": "Чтение кеша / (Чтение кеша + Создание кеша + Ввод без кеша)",
|
||||
"7e9e334aeb0bdc07": "Не удалось выполнить операцию с сервисом",
|
||||
"7f68ebad19ba6bcd": "Проверить обновления",
|
||||
"80296f4aa3f4543b": "Чтение/запись кеша",
|
||||
"81123c56d5d880d0": "Ключ API",
|
||||
"8139cb3dd11f5a67": "Если включено, объект JSON переопределит итоговые заголовки запроса. При совпадении имен используются значения отсюда; все значения должны быть строками.",
|
||||
"8151e8704a7ca89e": "Совпадений нет",
|
||||
"83913e71fcf7ff60": "Обновление выполнено",
|
||||
"83fcfb4c1f2c1641": "Получить модели",
|
||||
"8672864e90417138": "Максимальная",
|
||||
"86df7ec743047234": "Сервис запущен",
|
||||
"87ed126f7bd1121e": "Режим маршрутизации",
|
||||
"899add6275682210": "Если оставить пустым, используется 200000",
|
||||
"8a4ef3e48e4e8a5a": "Включено",
|
||||
"8c0d84831a3c3d5b": "Сейчас используется режим локального сервиса",
|
||||
"8c1935935600e336": "Проверка модели",
|
||||
"8cbcf741e727dbf7": "Настройки модели",
|
||||
"8d1de152be6360ce": "Доля успешных: {0}",
|
||||
"8e2dc7b0d2e8f6f8": "например, OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "Скопировано",
|
||||
"8f8baf5d18dd0492": "Если включено, объект JSON переопределит тело запроса OpenAI. При совпадении полей используются значения отсюда. OpenAI service_tier поддерживает auto, default, flex, scale и priority; priority можно использовать для сценариев с высоким приоритетом/Fast.",
|
||||
"8faa670b512b6b9b": "Открыть настройки модели",
|
||||
@@ -123,9 +116,7 @@
|
||||
"917b1c1f18d0276b": "Сохранение...",
|
||||
"9196835e388d2550": "Проверить все",
|
||||
"91cba5c107a51892": "/ Ошибочные",
|
||||
"92059fe6cd713db4": "Имя модели, которое фактически отправляется серверу, например gpt-4.1 или claude-sonnet.",
|
||||
"93e08803675e378b": "Идентификатор модели",
|
||||
"93faf55cd25c8319": "Это программное обеспечение полностью бесплатно. Если с вас взяли плату, скорее всего, вас обманули.\\nПосетите страницу автора: https://space.bilibili.com/311706663/upload/video\\nТам публикуются обновления, руководства и другие материалы.",
|
||||
"942ff2d88baca0c6": "Проверка обновлений...",
|
||||
"970388573a3c88c9": "Чтение кеша: {0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "Макс.",
|
||||
@@ -133,11 +124,9 @@
|
||||
"9970736b36ff2b68": "Корневой адрес API сервиса модели, обычно совместимый с OpenAI или Anthropic.",
|
||||
"9a6e7d6c17471711": "Сейчас используется стандартный расчет доли попаданий",
|
||||
"9b17fa889b307f7f": "Успешных ходов: {0}",
|
||||
"9c38b6e9bf94abec": "Включено прямое подключение к Cursor",
|
||||
"9c41b3a9e12ac994": "Интенсивность рассуждений",
|
||||
"9d2ca261281a158a": "Позже",
|
||||
"9d2fb46c0ba890b9": "Пользовательский путь",
|
||||
"9dc0825fba5422e4": "Загрузка...",
|
||||
"9e02529bcaef36c6": "Обнаружен повторяющийся канал модели. Проверьте сочетание url, modelID, apiKey, displayName и endpoint",
|
||||
"a026f37e613cf48b": "Выходные токены",
|
||||
"a0d36236c523667c": "Интенсивность рассуждений Anthropic для {0} поддерживает только low, medium, high, xhigh и max",
|
||||
@@ -146,24 +135,23 @@
|
||||
"a325d25c69e7256d": "Настройки модели не найдены; дублирование невозможно",
|
||||
"a4dd8bb7e8b6eb31": "Показать ключ API",
|
||||
"a54d745d9a9249e2": "Если включено, объект JSON переопределит тело запроса Anthropic. При совпадении полей используются значения отсюда.",
|
||||
"a55a88237df85d98": "Сейчас используется прямой режим",
|
||||
"a567bdaa11367f26": "Средняя",
|
||||
"a5f1bd344c92e195": "Ключ API, необходимый для обращения к сервису модели.",
|
||||
"a693d69af48bfe48": "Сохранить и проверить",
|
||||
"a98585871c5313ff": "Отображаемое имя",
|
||||
"aa9e366f68d3d097": "Низкая",
|
||||
"ab607d54d86dc789": "Автор leookun",
|
||||
"ac217e4d1ca410f1": "Доступна новая версия",
|
||||
"ad79540418be700a": "Открыть папку настроек или отдельно управлять настройками моделей",
|
||||
"ae5a738238463a92": "Скрыть ключ API",
|
||||
"aeb1e3a8ff46cda1": "Модели пока не настроены.",
|
||||
"aed55419ce62f08e": "Переключение...",
|
||||
"b10041a13f5c55b1": "Вывод модели: {0} × ${1}/1M = {2}",
|
||||
"b1c27820fec23edb": "Высокая",
|
||||
"b42049dcf8a05ef7": "Включен режим локального сервиса",
|
||||
"b5409d4049286061": "Пользовательский путь (введите полный URL запроса)",
|
||||
"b571037dc396a00c": "Общее число токенов запроса включает Prompt и вывод модели.",
|
||||
"b765005f69fa971f": "например, gpt-4.1",
|
||||
"b76a22622020f849": "Выберите конечную точку протокола. Для варианта «Пользовательский путь» укажите полный URL запроса в поле адреса API, включая суффикс /chat/completions или /responses. Тип протокола будет определен автоматически по последнему сегменту.",
|
||||
"b7ceef2fbfeb4a85": "например, GPT-5",
|
||||
"b870928f8f9a24c4": "Конечная точка API",
|
||||
"b90a8ac9c488ce46": "Выберите язык",
|
||||
"ba3c66f90fd11725": "Обнаружен системный прокси",
|
||||
@@ -174,19 +162,17 @@
|
||||
"bddd504af0c92fd0": "Обнаружен системный PAC/автоматический прокси; в текущей версии используется прямое подключение",
|
||||
"bef280f9eb392495": "Ходы диалога",
|
||||
"c228558cf257fc49": "Не удалось удалить",
|
||||
"c3e9c3c60020b8b7": "Выберите режим",
|
||||
"c5af02060847d167": "Интенсивность для адаптивных рассуждений Anthropic. В запросах всегда используется новый режим thinking.type=adaptive.",
|
||||
"c6868592796ac2b2": "Модели {0} пока не настроены.",
|
||||
"c69f5bce63b9f14c": "Папка настроек",
|
||||
"c8c14507b2d37395": "Интенсивность рассуждений",
|
||||
"c98e118e0a43f078": "Модель",
|
||||
"c9dd59beefd7144f": "Чтение кеша / (Чтение кеша + Ввод без кеша)",
|
||||
"ca00a39fcea70dc6": "Запуск...",
|
||||
"ca1d1059408b3837": "Ошибочных ходов: {0}",
|
||||
"cc5049729a2c10f1": "Тест не пройден. Проверьте исходные сведения.",
|
||||
"cd7ca5fb221e1c53": "{0} не может быть пустым",
|
||||
"ce46f23cea3bf3c5": "Если включено, Cursor подключается напрямую к официальному сервису. Не включайте этот режим.",
|
||||
"d0325067fed88e5a": "Доля попаданий в кеш: {0}",
|
||||
"d08fd4224abcd69d": "Не удалось переключить",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] Не удалось загрузить сведения об авторе",
|
||||
"d20ab96566d33f25": "Отображаемое имя {0} не может быть пустым",
|
||||
"d2243e1d44b2a94e": "Изменить настройки модели",
|
||||
"d3209b935ae86797": "Настройки модели не найдены; удаление невозможно",
|
||||
@@ -212,12 +198,15 @@
|
||||
"ec3b17a75db49e24": "{0} т/с | Первый токен {1}",
|
||||
"ec99e5c45d648fd6": "Не удалось обновить",
|
||||
"ee95057c6b0335d2": "Исходящие запросы используют системный прокси: {0}",
|
||||
"f0b6a23368dd47cc": "Введите идентификатор модели вручную или выберите его из списка, полученного от сервера.",
|
||||
"f1aa7326f38b4c09": "Перетащите, чтобы изменить порядок",
|
||||
"f1e0fc261d42fe29": "Примечание, отображаемое при наведении на модель в списке.",
|
||||
"f363622480699c52": "Интенсивность рассуждений применяется только к моделям с поддержкой reasoning_effort. Чем выше значение, тем обычно стабильнее результат, но ответ может формироваться медленнее.",
|
||||
"f3a76d896853c1df": "Промах",
|
||||
"f3fae6cccb9004b1": "Имя пользовательского заголовка не может быть пустым",
|
||||
"f474a4108aba4c4c": "Остановить сервис",
|
||||
"f4f0ead1116b5b62": "Включить",
|
||||
"f526ab6eff33039a": "Не удалось открыть страницу автора",
|
||||
"f56c6c82203b33f6": "Уведомление",
|
||||
"f61e03f047b786d5": "Максимальное число выходных токенов {0} должно быть положительным целым числом",
|
||||
"f6e1c8b1a6970db5": "Исходящие запросы не используют системный прокси",
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"02216368edc68816": "无更新说明",
|
||||
"02bc2e95bf49e587": "当前还没有配置任何",
|
||||
"03b11112dc970014": "接口地址",
|
||||
"047ec6b71d0cec08": "控制白名单主链路请求走本地服务,还是回到原始 Cursor 上游地址",
|
||||
"04f632dd4f034d5e": "{0} 的上下文窗口必须为正整数",
|
||||
"051836569928a9f9": "编辑",
|
||||
"054d763265603305": "例如:65536(留空用默认值)",
|
||||
"05c8a9238c702efa": "直连 Cursor 模式",
|
||||
"0647728439b5da2e": "可配置运行模式和模型渠道;运行日志位于",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0b0e7478e41fe677": "{0} 的悬停提示不能为空",
|
||||
"0c3b4cf7aa259edb": "操作失败",
|
||||
"0dde813d719dbd01": "打开主页失败",
|
||||
"0d6b7efd5ccefd8a": "可配置模型渠道;运行日志位于",
|
||||
"1117a2f86030d03b": "缓存读写已计入 Prompt 侧统计。",
|
||||
"11afd2a534395b18": "有效",
|
||||
"124be3f86f197802": "Token 消耗",
|
||||
@@ -20,7 +16,7 @@
|
||||
"15d124b200ddabed": "模型单次可接受的最大上下文 Token 数。留空时使用默认值。",
|
||||
"185aebe19c77425d": "{0}必须是 JSON 对象",
|
||||
"18b7312022cd1840": "启动服务",
|
||||
"1af38868896cf53d": "运行模式仅支持 local 或 upstream",
|
||||
"1afed6a81a2512d2": "选择模型",
|
||||
"1baddde657dd2720": "当前出站请求使用系统代理",
|
||||
"1bc77f5ab979f4c1": "新增模型配置",
|
||||
"1e238093b79b3165": "留空时默认 65536",
|
||||
@@ -30,7 +26,6 @@
|
||||
"26a3855aed1d8d17": "服务未启动",
|
||||
"281eb6d08c9960d0": "{0} 的思考预算 Token 必须为正整数",
|
||||
"28aeffc70ceb4267": "切换当前界面显示语言,设置会立即生效并保存在本机",
|
||||
"2a24519398684ed5": "访问主页",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d706f7981b45a7b": "本地配置已保存",
|
||||
"2f9daa828907b93f": "删除",
|
||||
@@ -41,13 +36,10 @@
|
||||
"3463c5585c246df9": "合计:{0}",
|
||||
"3468b57e3edbc599": "按历史记录里扫描到的回合 summary 汇总。",
|
||||
"35076178fe79a210": "配置已变更,请重新测试",
|
||||
"358f07b2c1445ab1": "作者寄语",
|
||||
"36c149a9b3e8dca0": "模型。",
|
||||
"37d23612f78a2e63": "立即重启更新",
|
||||
"392d0dceb45998d3": "极高",
|
||||
"393df9bb13ea4900": "命中",
|
||||
"3af7e5489e61ea51": "刷新中",
|
||||
"3bf8512aa520ed21": "本地服务模式",
|
||||
"3c2a9f9901109e75": "{0} 的类型仅支持 OpenAI 或 Anthropic",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3ea83f9f55062582": "发布时间:{0}",
|
||||
@@ -60,17 +52,19 @@
|
||||
"472642d58d3d5a6d": "公式:{0}",
|
||||
"4923eeb7bd75cccd": "{0} 的模型标识不能为空",
|
||||
"497c85690c4cc0fc": "暂无数据",
|
||||
"4b5e0ae1288a9695": "没有匹配的模型",
|
||||
"4c0a929bb86ce912": "当前:{0}",
|
||||
"4d2b6e53be6002e5": "缓存统计策略:{0}({1})",
|
||||
"4d8c1c5b42830791": "未知",
|
||||
"4f0982ba1d37e51b": "当前出站请求使用环境变量代理",
|
||||
"51194c3ad014fb29": "需重测",
|
||||
"5205125c0e91d346": "Anthropic 模型单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"54e6745ff43c9c74": "排序失败",
|
||||
"56627c94a9decee6": "最大输出 Token",
|
||||
"593a972852ba0004": "Cursor助手|永久免费|自定义API",
|
||||
"59a2195a01a8b35b": "{0}必须是合法 JSON 对象",
|
||||
"5aa8f5590c940829": "非缓存输入:{0}",
|
||||
"5beb1206c532729f": "单次回复允许生成的最大 Token 数。留空时使用默认值。",
|
||||
"5c55a67935af8f45": "全部",
|
||||
"5d1687a4a41883fd": "停止中...",
|
||||
"5e709712ce012f5d": "当前出站请求使用环境变量代理:{0}",
|
||||
"6106f0a12583a334": "刷新失败",
|
||||
@@ -84,7 +78,6 @@
|
||||
"66af574b8948fe83": "{0} 的访问密钥不能为空",
|
||||
"6744b4c6a9aa0038": "已关闭",
|
||||
"675109292da4eb36": "尚未测试",
|
||||
"699fe7ade5407687": "直连模式",
|
||||
"6a7b96f399e58138": "例如:sk-xxxxxx",
|
||||
"6aa8f49cc992dfd7": "测试",
|
||||
"6ae23d6d7cb18592": "服务错误",
|
||||
@@ -92,6 +85,7 @@
|
||||
"6e584e3d5ce64aa0": "保存配置",
|
||||
"6ec87609a8769425": "默认 {0} / 计入创建 {1}",
|
||||
"72f6c3525c0192a7": "开启后把缓存创建纳入分母",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"737225e2904673fc": "输出推算:{0}",
|
||||
"7520bd50a5ee5471": "停止测试 {0}/{1}",
|
||||
"753d8bb0da9913ce": "复制失败",
|
||||
@@ -99,23 +93,22 @@
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"7a26bf794e9fb6bf": "仅用于界面展示,便于你区分不同模型。",
|
||||
"7b6187c41e88b70c": "测试中...",
|
||||
"7bf8e2c07e084d09": "模型编辑",
|
||||
"7df7641e5e741346": "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)",
|
||||
"7e9e334aeb0bdc07": "服务操作失败",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"80296f4aa3f4543b": "缓存读写",
|
||||
"81123c56d5d880d0": "访问密钥",
|
||||
"8139cb3dd11f5a67": "开启后会把 JSON 对象覆盖到最终请求头。同名请求头以这里为准,值必须是字符串。",
|
||||
"8151e8704a7ca89e": "没有匹配项",
|
||||
"83913e71fcf7ff60": "刷新成功",
|
||||
"83fcfb4c1f2c1641": "获取模型",
|
||||
"8672864e90417138": "最高",
|
||||
"86df7ec743047234": "服务运行中",
|
||||
"87ed126f7bd1121e": "运行模式",
|
||||
"899add6275682210": "留空时默认 200000",
|
||||
"8a4ef3e48e4e8a5a": "已开启",
|
||||
"8c0d84831a3c3d5b": "当前为本地服务模式",
|
||||
"8c1935935600e336": "模型测试",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8d1de152be6360ce": "有效占比:{0}",
|
||||
"8e2dc7b0d2e8f6f8": "例如:OpenAI - GPT-4.1",
|
||||
"8f6f8d979c981ced": "已复制",
|
||||
"8f8baf5d18dd0492": "开启后会把 JSON 对象覆盖到 OpenAI 请求体。同名字段以这里为准。OpenAI service_tier 支持 auto、default、flex、scale、priority;priority 可用于高优先级/Fast 类场景。",
|
||||
"8faa670b512b6b9b": "打开模型配置",
|
||||
@@ -123,9 +116,7 @@
|
||||
"917b1c1f18d0276b": "保存中...",
|
||||
"9196835e388d2550": "测试全部",
|
||||
"91cba5c107a51892": "/ 异常",
|
||||
"92059fe6cd713db4": "请求实际发送给服务端的模型名称,例如 gpt-4.1 或 claude-sonnet。",
|
||||
"93e08803675e378b": "模型标识",
|
||||
"93faf55cd25c8319": "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。",
|
||||
"942ff2d88baca0c6": "检查更新中...",
|
||||
"970388573a3c88c9": "缓存读取:{0} × ${1}/1M = {2}",
|
||||
"9730c15f3c1963a1": "最大",
|
||||
@@ -133,11 +124,9 @@
|
||||
"9970736b36ff2b68": "模型服务的 API 根地址,通常为兼容 OpenAI 或 Anthropic 的接口入口。",
|
||||
"9a6e7d6c17471711": "当前按默认命中率口径显示",
|
||||
"9b17fa889b307f7f": "有效轮次:{0}",
|
||||
"9c38b6e9bf94abec": "已切换到直连 Cursor 模式",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9d2ca261281a158a": "稍后",
|
||||
"9d2fb46c0ba890b9": "自定义路径",
|
||||
"9dc0825fba5422e4": "加载中...",
|
||||
"9e02529bcaef36c6": "模型渠道重复,请检查 url、modelID、apiKey、displayName、endpoint 组合",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a0d36236c523667c": "{0} 的 Anthropic 思考强度仅支持 low、medium、high、xhigh、max",
|
||||
@@ -146,24 +135,23 @@
|
||||
"a325d25c69e7256d": "模型配置不存在,无法复制",
|
||||
"a4dd8bb7e8b6eb31": "显示访问密钥",
|
||||
"a54d745d9a9249e2": "开启后会把 JSON 对象覆盖到 Anthropic 请求体。同名字段以这里为准。",
|
||||
"a55a88237df85d98": "当前为直连模式",
|
||||
"a567bdaa11367f26": "中",
|
||||
"a5f1bd344c92e195": "调用该模型服务需要使用的访问密钥。",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"aa9e366f68d3d097": "低",
|
||||
"ab607d54d86dc789": "作者 leookun",
|
||||
"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": "已识别系统代理",
|
||||
@@ -174,19 +162,17 @@
|
||||
"bddd504af0c92fd0": "检测到系统 PAC/自动代理,当前版本按直连处理",
|
||||
"bef280f9eb392495": "对话轮次",
|
||||
"c228558cf257fc49": "删除失败",
|
||||
"c3e9c3c60020b8b7": "选择模式",
|
||||
"c5af02060847d167": "Anthropic adaptive thinking 的思考强度。请求会固定使用新版 thinking.type=adaptive。",
|
||||
"c6868592796ac2b2": "当前还没有配置任何 {0} 模型。",
|
||||
"c69f5bce63b9f14c": "设置文件夹",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c98e118e0a43f078": "模型",
|
||||
"c9dd59beefd7144f": "缓存读取 /(缓存读取 + 非缓存输入)",
|
||||
"ca00a39fcea70dc6": "启动中...",
|
||||
"ca1d1059408b3837": "异常轮次:{0}",
|
||||
"cc5049729a2c10f1": "测试失败,请查看原始信息",
|
||||
"cd7ca5fb221e1c53": "{0}不能为空",
|
||||
"ce46f23cea3bf3c5": "开启后,Cursor将直接接通官方,请勿开启",
|
||||
"d0325067fed88e5a": "缓存命中率 {0}",
|
||||
"d08fd4224abcd69d": "切换失败",
|
||||
"d1bde4a4e057b2c7": "[MainLayout] 加载作者信息失败",
|
||||
"d20ab96566d33f25": "{0} 的显示名称不能为空",
|
||||
"d2243e1d44b2a94e": "编辑模型配置",
|
||||
"d3209b935ae86797": "模型配置不存在,无法删除",
|
||||
@@ -212,12 +198,15 @@
|
||||
"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": "当前出站请求未使用系统代理",
|
||||
|
||||
@@ -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,18 +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 localizedAuthorInfo = computed(() => {
|
||||
if (!footerAuthorInfo.value) return null;
|
||||
return {
|
||||
buttonText: "作者 leookun",
|
||||
dialogTitle: "作者寄语",
|
||||
dialogContent: "本软件是纯免费软件,如果你被收费,那大概率就是被骗了。\n欢迎点击访问作者主页 https://space.bilibili.com/311706663/upload/video\n查看更多更新动态、使用分享和后续内容。",
|
||||
dialogConfirmText: "访问主页",
|
||||
dialogCancelText: "关闭",
|
||||
};
|
||||
});
|
||||
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;
|
||||
@@ -89,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 {
|
||||
@@ -99,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 (!localizedAuthorInfo.value) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await showModal({
|
||||
title: localizedAuthorInfo.value.dialogTitle,
|
||||
content: localizedAuthorInfo.value.dialogContent,
|
||||
confirmText: localizedAuthorInfo.value.dialogConfirmText,
|
||||
cancelText: localizedAuthorInfo.value.dialogCancelText,
|
||||
showCancel: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openFooterAuthorHome();
|
||||
await Browser.OpenURL(AUTHOR_REPOSITORY_URL);
|
||||
} catch (error) {
|
||||
await showActionError("打开主页失败", error);
|
||||
showActionError("打开作者地址失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,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(() => {});
|
||||
@@ -175,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
|
||||
@@ -203,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"
|
||||
@@ -232,13 +191,12 @@ onUnmounted(() => {
|
||||
<span>使用教程</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="localizedAuthorInfo"
|
||||
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>{{ localizedAuthorInfo.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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
GetAppVersion,
|
||||
GetFooterAuthorInfo,
|
||||
InstallReadyUpdate,
|
||||
GetModelEditorContext,
|
||||
OpenConfigWindow,
|
||||
OpenFooterAuthorHome,
|
||||
OpenHistoryWindow,
|
||||
OpenModelConfigWindow,
|
||||
OpenModelEditorWindow,
|
||||
} from "@bindings/cursor/internal/bridge/windowservice.js";
|
||||
import { Call } from "@wailsio/runtime";
|
||||
|
||||
@@ -118,16 +116,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 +134,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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
loadUserConfig,
|
||||
openLogsDirectory,
|
||||
openModelConfig,
|
||||
openModelEditor,
|
||||
saveUserConfig,
|
||||
startProxyService,
|
||||
stopProxyService,
|
||||
testModelAdapter,
|
||||
fetchModelAdapterModels,
|
||||
} from "@/services/clientApi";
|
||||
|
||||
const APP_STATE_STORAGE_KEY = "cursor-client:runtime-state:v2";
|
||||
@@ -36,7 +36,6 @@ 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, OPENAI_ENDPOINT_CUSTOM]);
|
||||
const SUPPORTED_ROUTE_MODES = new Set(["local", "upstream"]);
|
||||
const PROXY_STATE_EVENT = "proxy:state";
|
||||
const USER_CONFIG_CHANGED_EVENT = "user-config:changed";
|
||||
const UPDATE_STATE_EVENT = "update:state";
|
||||
@@ -47,11 +46,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();
|
||||
@@ -126,14 +120,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) {
|
||||
@@ -240,7 +226,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) {
|
||||
@@ -261,6 +247,7 @@ function normalizeModelAdapterTestResults(source) {
|
||||
export function createEmptyModelAdapter() {
|
||||
return {
|
||||
id: "",
|
||||
sort: 0,
|
||||
displayName: "",
|
||||
type: "openai",
|
||||
baseURL: "",
|
||||
@@ -368,6 +355,7 @@ 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),
|
||||
@@ -405,7 +393,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) {
|
||||
@@ -413,9 +423,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`;
|
||||
}
|
||||
@@ -425,15 +432,27 @@ 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.contextWindowTokens && (!Number.isInteger(adapter.contextWindowTokens) || adapter.contextWindowTokens <= 0)) {
|
||||
return `${prefix} 的上下文窗口必须为正整数`;
|
||||
}
|
||||
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 或以 / 开头的自定义路径`;
|
||||
}
|
||||
@@ -443,29 +462,20 @@ export function validateModelAdapters(source) {
|
||||
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.customHeadersEnabled) {
|
||||
const customHeadersError = validateHeadersJSON(adapter.customHeadersJSON);
|
||||
if (customHeadersError) {
|
||||
return `${prefix} 的 ${customHeadersError}`;
|
||||
}
|
||||
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 必须为正整数`;
|
||||
@@ -479,13 +489,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";
|
||||
}
|
||||
@@ -531,7 +534,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),
|
||||
@@ -539,9 +541,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),
|
||||
},
|
||||
@@ -584,7 +583,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,
|
||||
};
|
||||
@@ -599,7 +597,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;
|
||||
}
|
||||
@@ -610,13 +607,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 {
|
||||
@@ -830,7 +820,6 @@ export const appState = reactive({
|
||||
modelAdapterTestResults: {},
|
||||
configBackendListenAddr: cachedConfig.backendListenAddr,
|
||||
configProxyListenAddr: cachedConfig.proxyListenAddr,
|
||||
routingMode: cachedConfig.routing.mode,
|
||||
includeCacheWriteInHitRate: cachedConfig.homeMetrics.includeCacheWriteInHitRate,
|
||||
|
||||
serviceRunning: asBoolean(cachedState.serviceRunning),
|
||||
@@ -1118,9 +1107,6 @@ export async function persistUserConfig() {
|
||||
return persistConfigPayload({
|
||||
...currentConfig,
|
||||
modelAdapters: normalizeModelAdapters(appState.modelAdapters),
|
||||
routing: {
|
||||
mode: appState.routingMode,
|
||||
},
|
||||
homeMetrics: {
|
||||
...currentConfig.homeMetrics,
|
||||
includeCacheWriteInHitRate: appState.includeCacheWriteInHitRate,
|
||||
@@ -1146,16 +1132,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);
|
||||
@@ -1191,6 +1167,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);
|
||||
@@ -1213,6 +1196,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+))?$/);
|
||||
@@ -1356,11 +1372,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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -30,8 +37,39 @@ body,
|
||||
}
|
||||
: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>
|
||||
|
||||
+17
-38
@@ -1,17 +1,14 @@
|
||||
<script setup>
|
||||
import Button from "@/components/ui/Button.vue";
|
||||
import Card from "@/components/ui/Card.vue";
|
||||
import Switch from "@/components/ui/Switch.vue";
|
||||
import HomeMetricsCard from "@/components/HomeMetricsCard.vue";
|
||||
import { useMessage } from "@/composables/useMessage";
|
||||
import { showModal } from "@/composables/useModal";
|
||||
import { getAdRuntime } from "@/services/clientApi";
|
||||
import {
|
||||
appState,
|
||||
appViewState,
|
||||
openConfigWindow,
|
||||
openModelConfigWindow,
|
||||
saveRoutingMode,
|
||||
syncHomeMetrics,
|
||||
syncServiceState,
|
||||
toUserError,
|
||||
@@ -20,10 +17,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 +79,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 +97,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 +122,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 +139,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 +151,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 +170,10 @@ 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>
|
||||
<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",
|
||||
};
|
||||
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 openModelEditorWindow(index, adapter);
|
||||
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 showActionError("打开失败", toUserError(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 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="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"
|
||||
|
||||
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 |
+24
-6
@@ -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 类型。
|
||||
@@ -74,7 +78,7 @@ func Run(resources EmbeddedResources) error {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
|
||||
defaultBackendBaseURL := browserReachableLoopbackBaseURL(serverconfig.DefaultBackendListenAddr)
|
||||
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,6 +138,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,9 +215,9 @@ 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: "/",
|
||||
@@ -415,16 +422,27 @@ 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) == "" {
|
||||
return "http://" + serverconfig.DefaultBackendListenAddr
|
||||
return "https://localhost:8000"
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return "http://" + net.JoinHostPort(host, port)
|
||||
if host == "127.0.0.1" || host == "::1" || host == "localhost" {
|
||||
host = "localhost"
|
||||
}
|
||||
return "https://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -134,7 +133,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` 不保存可投射历史。
|
||||
|
||||
@@ -1134,10 +1134,20 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1175,7 +1185,15 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -1284,7 +1302,7 @@ func isAnthropicImageBlock(block map[string]any) bool {
|
||||
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
|
||||
}
|
||||
|
||||
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool) ([]map[string]any, error) {
|
||||
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
|
||||
blocks, err := anthropicContentBlocks(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1293,11 +1311,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
|
||||
@@ -1381,9 +1405,6 @@ func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool)
|
||||
if strings.TrimSpace(message.Role) != "assistant" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(message.ReasoningContent) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -839,7 +839,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)
|
||||
}
|
||||
@@ -1903,11 +1903,13 @@ func openAIThinkingDisableKind(baseURL string, modelID string, endpoint string)
|
||||
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, "mimo"):
|
||||
strings.Contains(model, "mimo") ||
|
||||
strings.Contains(model, "minimax"):
|
||||
return "thinking_type"
|
||||
case openAIModelSupportsReasoningNone(model):
|
||||
return "reasoning_none"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1008,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)
|
||||
|
||||
@@ -19,87 +19,119 @@ type usageLookupRecord struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type aiHandler struct {
|
||||
mux *http.ServeMux
|
||||
paths map[string]struct{}
|
||||
}
|
||||
|
||||
func newAIHandlerMux() *aiHandler {
|
||||
return &aiHandler{
|
||||
mux: http.NewServeMux(),
|
||||
paths: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *aiHandler) Handle(pattern string, target http.Handler) {
|
||||
handler.paths[pattern] = struct{}{}
|
||||
handler.mux.Handle(pattern, target)
|
||||
}
|
||||
|
||||
func (handler *aiHandler) HandlesPath(path string) bool {
|
||||
if handler == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := handler.paths[path]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (handler *aiHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if handler == nil || handler.mux == nil {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
handler.mux.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
const (
|
||||
dashboardServiceGetTokenUsageProcedure = "/aiserver.v1.DashboardService/GetTokenUsage"
|
||||
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure = "/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment"
|
||||
)
|
||||
|
||||
func newAIHandler(service *Service) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(
|
||||
func newAIHandler(service *Service) *aiHandler {
|
||||
handler := newAIHandlerMux()
|
||||
handler.Handle(
|
||||
dashboardServiceGetTokenUsageProcedure,
|
||||
connect.NewUnaryHandler(dashboardServiceGetTokenUsageProcedure, service.GetTokenUsage),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure,
|
||||
connect.NewUnaryHandler(dashboardServiceGetGlassEarlyPreviewEnrollmentProcedure, service.GetGlassEarlyPreviewEnrollment),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceCountTokensProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceCountTokensProcedure, service.CountTokens),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceGetThoughtAnnotationProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceGetThoughtAnnotationProcedure, service.GetThoughtAnnotation),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceWriteGitCommitMessageProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceWriteGitCommitMessageProcedure, service.WriteGitCommitMessage),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceCreateExperimentalIndexProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceCreateExperimentalIndexProcedure, service.CreateExperimentalIndex),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceListExperimentalIndexFilesProcedure, service.ListExperimentalIndexFiles),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceListenExperimentalIndexProcedure,
|
||||
connect.NewServerStreamHandler(aiserverv1connect.AiServiceListenExperimentalIndexProcedure, service.ListenExperimentalIndex),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceRegisterFileToIndexProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceRegisterFileToIndexProcedure, service.RegisterFileToIndex),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceSetupIndexDependenciesProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceSetupIndexDependenciesProcedure, service.SetupIndexDependencies),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceComputeIndexTopoSortProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceComputeIndexTopoSortProcedure, service.ComputeIndexTopoSort),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceDocumentationQueryProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceDocumentationQueryProcedure, service.DocumentationQuery),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceAvailableDocsProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceAvailableDocsProcedure, service.AvailableDocs),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceKnowledgeBaseAddProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseAddProcedure, service.KnowledgeBaseAdd),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceKnowledgeBaseListProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseListProcedure, service.KnowledgeBaseList),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseRemoveProcedure, service.KnowledgeBaseRemove),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceKnowledgeBaseUpdateProcedure, service.KnowledgeBaseUpdate),
|
||||
)
|
||||
mux.Handle(
|
||||
handler.Handle(
|
||||
aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure,
|
||||
connect.NewUnaryHandler(aiserverv1connect.AiServiceFetchRelevantKnowledgeForConversationProcedure, service.FetchRelevantKnowledgeForConversation),
|
||||
)
|
||||
mux.Handle("/", http.NotFoundHandler())
|
||||
return mux
|
||||
return handler
|
||||
}
|
||||
|
||||
func (service *Service) GetThoughtAnnotation(_ context.Context, req *connect.Request[aiserverv1.GetThoughtAnnotationRequest]) (*connect.Response[aiserverv1.GetThoughtAnnotationResponse], error) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cursor/gen/aiserverv1/aiserverv1connect"
|
||||
)
|
||||
|
||||
func TestAIHandlerTracksLocallyImplementedPaths(t *testing.T) {
|
||||
handler := newAIHandler(&Service{})
|
||||
if !handler.HandlesPath(aiserverv1connect.AiServiceCountTokensProcedure) {
|
||||
t.Fatalf("expected %q to be handled locally", aiserverv1connect.AiServiceCountTokensProcedure)
|
||||
}
|
||||
if !handler.HandlesPath(dashboardServiceGetTokenUsageProcedure) {
|
||||
t.Fatalf("expected %q to be handled locally", dashboardServiceGetTokenUsageProcedure)
|
||||
}
|
||||
if handler.HandlesPath("/aiserver.v1.AiService/UnknownProcedure") {
|
||||
t.Fatal("unknown AI procedure must fall through to upstream")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -76,6 +76,12 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
||||
if existing.BackgroundShellActions == nil {
|
||||
existing.BackgroundShellActions = make(map[string]time.Time)
|
||||
}
|
||||
if existing.PendingCheckpointBlobWrites == nil {
|
||||
existing.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
}
|
||||
if existing.ConfirmedCheckpointBlobs == nil {
|
||||
existing.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||
}
|
||||
existing.UpdatedAt = time.Now().UTC()
|
||||
existing.mu.Unlock()
|
||||
return existing, nil
|
||||
@@ -102,6 +108,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
|
||||
BackgroundShellsByMessageID: make(map[uint32]string),
|
||||
BackgroundShellsByExecID: make(map[string]string),
|
||||
BackgroundShellActions: make(map[string]time.Time),
|
||||
PendingCheckpointBlobWrites: make(map[uint32]string),
|
||||
ConfirmedCheckpointBlobs: make(map[string]struct{}),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
const checkpointBlobWriteTimeout = 5 * time.Second
|
||||
|
||||
type pendingCheckpointBlobWrite struct {
|
||||
requestID uint32
|
||||
blob CheckpointBlob
|
||||
}
|
||||
|
||||
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
|
||||
if completion == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *completion
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
|
||||
if service == nil || stream == nil || projection == nil || projection.State == nil {
|
||||
return nil
|
||||
}
|
||||
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
|
||||
if !ok || state == nil {
|
||||
return fmt.Errorf("clone checkpoint state")
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
if stream.PendingCheckpointBlobWrites == nil {
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
}
|
||||
if stream.ConfirmedCheckpointBlobs == nil {
|
||||
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||
}
|
||||
if completion == nil && stream.PendingCheckpoint != nil {
|
||||
completion = stream.PendingCheckpoint.Completion
|
||||
}
|
||||
required := make(map[string]struct{}, len(projection.Blobs))
|
||||
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
|
||||
for _, key := range stream.PendingCheckpointBlobWrites {
|
||||
pendingKeys[key] = struct{}{}
|
||||
}
|
||||
toWrite := make([]pendingCheckpointBlobWrite, 0, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
key := string(blob.ID)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
required[key] = struct{}{}
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; confirmed {
|
||||
continue
|
||||
}
|
||||
if _, pending := pendingKeys[key]; pending {
|
||||
continue
|
||||
}
|
||||
stream.NextCheckpointBlobRequestID++
|
||||
if stream.NextCheckpointBlobRequestID == 0 {
|
||||
stream.NextCheckpointBlobRequestID++
|
||||
}
|
||||
requestID := stream.NextCheckpointBlobRequestID
|
||||
stream.PendingCheckpointBlobWrites[requestID] = key
|
||||
pendingKeys[key] = struct{}{}
|
||||
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
|
||||
}
|
||||
stream.PendingCheckpoint = &pendingCheckpointPublish{
|
||||
State: state,
|
||||
Required: required,
|
||||
Completion: clonePendingTurnCompletion(completion),
|
||||
}
|
||||
if completion != nil {
|
||||
stream.Phase = TurnPhaseCheckpointing
|
||||
}
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
|
||||
for _, write := range toWrite {
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||
Message: buildSetCheckpointBlobMessage(write.requestID, write.blob),
|
||||
}); err != nil {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish checkpoint blob: %w", err))
|
||||
}
|
||||
}
|
||||
if service.checkpointProjectionReady(stream) {
|
||||
return service.publishReadyCheckpoint(stream)
|
||||
}
|
||||
// Keep the latest live UI state ahead of an immediate client abort. Blob writes are
|
||||
// ordered before this snapshot; acknowledgements still gate terminal completion.
|
||||
if completion == nil {
|
||||
if err := service.publishPendingCheckpoint(stream); err != nil {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish pending checkpoint: %w", err))
|
||||
}
|
||||
}
|
||||
service.scheduleStreamTimer(
|
||||
stream,
|
||||
providerTimerKey(streamTimerCheckpointBlobs, ""),
|
||||
checkpointBlobWriteTimeout,
|
||||
streamTimerCheckpointBlobs,
|
||||
"",
|
||||
0,
|
||||
"checkpoint blob write timeout",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) publishPendingCheckpoint(stream *ActiveStream) error {
|
||||
if service == nil || stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
if pending == nil || pending.Published {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
pending.Published = true
|
||||
state := pending.State
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
stream.mu.Lock()
|
||||
if stream.PendingCheckpoint == pending {
|
||||
pending.Published = false
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if stream.PendingCheckpoint == nil {
|
||||
return false
|
||||
}
|
||||
for key := range stream.PendingCheckpoint.Required {
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
|
||||
if service == nil || stream == nil || message == nil || message.GetSetBlobResult() == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
key, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
|
||||
if ok {
|
||||
delete(stream.PendingCheckpointBlobWrites, message.GetId())
|
||||
}
|
||||
required := false
|
||||
if ok && stream.PendingCheckpoint != nil {
|
||||
_, required = stream.PendingCheckpoint.Required[key]
|
||||
}
|
||||
if ok && message.GetSetBlobResult().GetError() == nil {
|
||||
stream.ConfirmedCheckpointBlobs[key] = struct{}{}
|
||||
}
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if blobErr := message.GetSetBlobResult().GetError(); blobErr != nil && required {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf(
|
||||
"client rejected checkpoint blob %s: %s",
|
||||
hex.EncodeToString([]byte(key)),
|
||||
firstNonEmpty(strings.TrimSpace(blobErr.GetMessage()), "unknown error"),
|
||||
))
|
||||
}
|
||||
if service.checkpointProjectionReady(stream) {
|
||||
return service.publishReadyCheckpoint(stream)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
|
||||
if service == nil || stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
if pending == nil {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
for key := range pending.Required {
|
||||
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
stream.PendingCheckpoint = nil
|
||||
state := pending.State
|
||||
completion := clonePendingTurnCompletion(pending.Completion)
|
||||
published := pending.Published
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if !published {
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
if completion != nil {
|
||||
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pendingCount := len(stream.PendingCheckpointBlobWrites)
|
||||
stream.mu.Unlock()
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
|
||||
}
|
||||
|
||||
func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, cause error) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
stream.PendingCheckpoint = nil
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if cause != nil {
|
||||
log.Printf("forwarder checkpoint blob sync skipped request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||
}
|
||||
if pending != nil && pending.Completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *pending.Completion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
stream.mu.Lock()
|
||||
stream.PendingCheckpoint = nil
|
||||
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if strings.TrimSpace(reason) != "" {
|
||||
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s reason=%s", stream.RequestID, stream.ConversationID, strings.TrimSpace(reason))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
if len(events) != len(projection.Blobs)+1 {
|
||||
t.Fatalf("events before ACK = %d, want %d Blob writes and one checkpoint", len(events), len(projection.Blobs))
|
||||
}
|
||||
for _, event := range events[:len(projection.Blobs)] {
|
||||
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
|
||||
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
|
||||
}
|
||||
}
|
||||
if checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate(); checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
|
||||
t.Fatalf("last event before ACK = %#v, want one Blob-backed turn", events[len(events)-1].Message)
|
||||
}
|
||||
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
events = readCheckpointTestEvents(t, service, stream)
|
||||
checkpointCount := 0
|
||||
for _, event := range events {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||
checkpointCount++
|
||||
}
|
||||
}
|
||||
if checkpointCount != 1 {
|
||||
t.Fatalf("checkpoints after ACK = %d, want 1", checkpointCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
completion := &pendingTurnCompletion{
|
||||
RequestID: stream.RequestID,
|
||||
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||
}
|
||||
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
eventsBeforeACK := readCheckpointTestEvents(t, service, stream)
|
||||
for _, event := range eventsBeforeACK {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil || event.Message.GetInteractionUpdate().GetTurnEnded() != nil || event.End {
|
||||
t.Fatalf("event before ACK = %#v, want only Blob writes", event)
|
||||
}
|
||||
}
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
|
||||
for index, event := range events {
|
||||
switch {
|
||||
case event.Message.GetConversationCheckpointUpdate() != nil:
|
||||
checkpointIndex = index
|
||||
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
|
||||
turnEndedIndex = index
|
||||
case event.End:
|
||||
endIndex = index
|
||||
}
|
||||
}
|
||||
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
|
||||
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
completion := &pendingTurnCompletion{
|
||||
RequestID: stream.RequestID,
|
||||
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
|
||||
}
|
||||
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
|
||||
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
|
||||
}
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
var checkpoint, turnEnded, successfulEnd bool
|
||||
for _, event := range events {
|
||||
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||
turnEnded = turnEnded || event.Message.GetInteractionUpdate().GetTurnEnded() != nil
|
||||
successfulEnd = successfulEnd || event.End && event.TerminalErrorCode == ""
|
||||
}
|
||||
if checkpoint || !turnEnded || !successfulEnd {
|
||||
t.Fatalf("timeout events checkpoint=%v turn_ended=%v successful_end=%v", checkpoint, turnEnded, successfulEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
eventsBeforeCancel := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointBeforeCancel := 0
|
||||
for _, event := range eventsBeforeCancel {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||
checkpointBeforeCancel++
|
||||
}
|
||||
}
|
||||
if checkpointBeforeCancel != 1 {
|
||||
t.Fatalf("checkpoints before cancel = %d, want 1", checkpointBeforeCancel)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||
requestIDs = append(requestIDs, requestID)
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
if err := service.handleCancelIntent(InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "user stopped",
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||
}
|
||||
for _, requestID := range requestIDs {
|
||||
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||
Id: requestID,
|
||||
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||
SetBlobResult: &agentv1.SetBlobResult{},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("late ACK %d error = %v", requestID, err)
|
||||
}
|
||||
}
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointCount := 0
|
||||
var canceledEnd bool
|
||||
for _, event := range events {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||
checkpointCount++
|
||||
}
|
||||
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
stream.mu.Unlock()
|
||||
if checkpointCount != 1 || !canceledEnd || pending != nil {
|
||||
t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
|
||||
}
|
||||
}
|
||||
|
||||
func testCheckpointBlobProjection(t *testing.T) (*Service, *ActiveStream, *CheckpointProjection) {
|
||||
t.Helper()
|
||||
broker := NewStreamBroker()
|
||||
service := &Service{
|
||||
store: NewConversationFileStore(t.TempDir()),
|
||||
projector: NewHistoryProjector(),
|
||||
broker: broker,
|
||||
}
|
||||
stream, err := broker.OpenStream(
|
||||
"request-1", "conversation-1", 1, "default", "default",
|
||||
agentv1.AgentMode_AGENT_MODE_AGENT, "hello",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStream() error = %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
testCheckpointUserEntry(t),
|
||||
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||
},
|
||||
}
|
||||
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
|
||||
t.Fatalf("replaceCheckpointConversation() error = %v", err)
|
||||
}
|
||||
return service, stream, projection
|
||||
}
|
||||
|
||||
func testCheckpointUserEntry(t *testing.T) HistoryEntry {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: "hello", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
return HistoryEntry{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: payload}
|
||||
}
|
||||
|
||||
func acknowledgeCheckpointBlobs(t *testing.T, service *Service, stream *ActiveStream) {
|
||||
t.Helper()
|
||||
for {
|
||||
stream.mu.Lock()
|
||||
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||
requestIDs = append(requestIDs, requestID)
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
if len(requestIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, requestID := range requestIDs {
|
||||
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||
Id: requestID,
|
||||
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||
SetBlobResult: &agentv1.SetBlobResult{},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", requestID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCheckpointTestEvents(t *testing.T, service *Service, stream *ActiveStream) []StreamEvent {
|
||||
t.Helper()
|
||||
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (service *Service) maybeCompactBeforeProvider(stream *ActiveStream, convers
|
||||
if service == nil || stream == nil || conversation == nil {
|
||||
return false, nil
|
||||
}
|
||||
manualInstruction, manual := parseManualCompactionDirective(stream.LatestUserText)
|
||||
manualInstruction, manual := streamManualCompactionDirective(stream)
|
||||
plan, err := service.buildCompactionPlan(stream, conversation, compiled, manual, manualInstruction)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -827,7 +827,7 @@ func buildFallbackCompactionSummary(plan *PendingCompaction) string {
|
||||
sections = append(sections, "Compaction note:\n"+truncateCompactionText(plan.HookMessage, 800))
|
||||
}
|
||||
if strings.TrimSpace(plan.ManualInstruction) != "" {
|
||||
sections = append(sections, "Manual compact instruction:\n"+truncateCompactionText(plan.ManualInstruction, 800))
|
||||
sections = append(sections, "Manual summarize instruction:\n"+truncateCompactionText(plan.ManualInstruction, 800))
|
||||
}
|
||||
return strings.TrimSpace(truncateCompactionText(strings.Join(sections, "\n\n"), compactionSummaryMaxChars))
|
||||
}
|
||||
@@ -875,13 +875,62 @@ func (service *Service) resolveCompactionReserveTokens(modelID string) int64 {
|
||||
return compactionAutoReserveTokens
|
||||
}
|
||||
|
||||
func parseManualCompactionRequest(userMessage *agentv1.UserMessage) (string, bool) {
|
||||
if userMessage == nil {
|
||||
return "", false
|
||||
}
|
||||
userText := strings.TrimSpace(userMessage.GetText())
|
||||
if instruction, ok := parseManualCompactionDirective(userText); ok {
|
||||
return instruction, true
|
||||
}
|
||||
if userText != "" {
|
||||
return "", false
|
||||
}
|
||||
selectedContext := userMessage.GetSelectedContext()
|
||||
if selectedContext == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, command := range selectedContext.GetCursorCommands() {
|
||||
if !isCursorSummarizeCommand(command) {
|
||||
continue
|
||||
}
|
||||
instruction, _ := parseManualCompactionDirective(command.GetContent())
|
||||
return instruction, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func streamManualCompactionDirective(stream *ActiveStream) (string, bool) {
|
||||
if stream == nil {
|
||||
return "", false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if stream.ManualCompaction.Requested {
|
||||
return strings.TrimSpace(stream.ManualCompaction.Instruction), true
|
||||
}
|
||||
return parseManualCompactionDirective(stream.LatestUserText)
|
||||
}
|
||||
|
||||
func isCursorSummarizeCommand(command *agentv1.SelectedCursorCommand) bool {
|
||||
if command == nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(command.GetName()), "glass-action-summarize") {
|
||||
return true
|
||||
}
|
||||
_, ok := parseManualCompactionDirective(command.GetContent())
|
||||
return ok
|
||||
}
|
||||
|
||||
func parseManualCompactionDirective(latestUserText string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(latestUserText)
|
||||
const directive = "/summarize"
|
||||
switch {
|
||||
case trimmed == "/compact":
|
||||
case trimmed == directive:
|
||||
return "", true
|
||||
case strings.HasPrefix(trimmed, "/compact "):
|
||||
return strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact")), true
|
||||
case strings.HasPrefix(trimmed, directive+" "):
|
||||
return strings.TrimSpace(strings.TrimPrefix(trimmed, directive)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -201,6 +201,41 @@ func buildShellOutputDeltaMessage(delta *agentv1.ShellOutputDeltaUpdate) *agentv
|
||||
}
|
||||
}
|
||||
|
||||
// buildShellToolCallDeltaMessage maps client shell output to the delta consumed by Cursor's terminal bubble.
|
||||
func buildShellToolCallDeltaMessage(callID string, modelCallID string, output *agentv1.ShellOutputDeltaUpdate) *agentv1.AgentServerMessage {
|
||||
if output == nil {
|
||||
return nil
|
||||
}
|
||||
var delta *agentv1.ShellToolCallDelta
|
||||
switch event := output.GetEvent().(type) {
|
||||
case *agentv1.ShellOutputDeltaUpdate_Stdout:
|
||||
content := event.Stdout.GetData()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
delta = &agentv1.ShellToolCallDelta{
|
||||
Delta: &agentv1.ShellToolCallDelta_Stdout{
|
||||
Stdout: &agentv1.ShellToolCallStdoutDelta{Content: content},
|
||||
},
|
||||
}
|
||||
case *agentv1.ShellOutputDeltaUpdate_Stderr:
|
||||
content := event.Stderr.GetData()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
delta = &agentv1.ShellToolCallDelta{
|
||||
Delta: &agentv1.ShellToolCallDelta_Stderr{
|
||||
Stderr: &agentv1.ShellToolCallStderrDelta{Content: content},
|
||||
},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return buildToolCallDeltaMessage(callID, modelCallID, &agentv1.ToolCallDelta{
|
||||
Delta: &agentv1.ToolCallDelta_ShellToolCallDelta{ShellToolCallDelta: delta},
|
||||
})
|
||||
}
|
||||
|
||||
// buildTurnEndedMessage 构造 turn 结束消息,并携带标准化后的 token 统计。
|
||||
func buildTurnEndedMessage(inputTokens int64, outputTokens int64, cacheReadTokens int64, cacheWriteTokens int64) *agentv1.AgentServerMessage {
|
||||
inputTokensValue := inputTokens
|
||||
@@ -245,6 +280,22 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
|
||||
}
|
||||
}
|
||||
|
||||
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
|
||||
return &agentv1.AgentServerMessage{
|
||||
Message: &agentv1.AgentServerMessage_KvServerMessage{
|
||||
KvServerMessage: &agentv1.KvServerMessage{
|
||||
Id: id,
|
||||
Message: &agentv1.KvServerMessage_SetBlobArgs{
|
||||
SetBlobArgs: &agentv1.SetBlobArgs{
|
||||
BlobId: append([]byte(nil), blob.ID...),
|
||||
BlobData: append([]byte(nil), blob.Data...),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
|
||||
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
|
||||
return &agentv1.AgentServerMessage{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -438,7 +439,11 @@ func (store *ConversationFileStore) writeConversationLocked(conversationID strin
|
||||
if err := store.writeContextLocked(conversationID, conversation); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.writeConversationMetaLocked(conversationID, conversation)
|
||||
if err := store.writeConversationMetaLocked(conversationID, conversation); err != nil {
|
||||
return err
|
||||
}
|
||||
store.syncCursorTranscriptBestEffort(conversationID, conversation)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *ConversationFileStore) writeConversationMetaLocked(conversationID string, conversation *ConversationFile) error {
|
||||
@@ -477,6 +482,70 @@ func (store *ConversationFileStore) writeContextLocked(conversationID string, co
|
||||
return writeJSONFileAtomic(store.contextPath(conversationID), context)
|
||||
}
|
||||
|
||||
func (store *ConversationFileStore) syncCursorTranscriptBestEffort(conversationID string, conversation *ConversationFile) {
|
||||
if store == nil || conversation == nil {
|
||||
return
|
||||
}
|
||||
folder := normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder)
|
||||
if folder == "" {
|
||||
return
|
||||
}
|
||||
if err := store.syncCursorTranscript(conversationID, conversation, folder); err != nil {
|
||||
log.Printf("forwarder transcript sync failed conversation_id=%s err=%v", strings.TrimSpace(conversationID), err)
|
||||
}
|
||||
}
|
||||
|
||||
func (store *ConversationFileStore) syncCursorTranscript(conversationID string, conversation *ConversationFile, transcriptsFolder string) error {
|
||||
return store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, transcriptsFolder, false)
|
||||
}
|
||||
|
||||
func (store *ConversationFileStore) syncCursorTranscriptWithLatestStatus(conversationID string, conversation *ConversationFile, transcriptsFolder string, includeLatestStatus bool) error {
|
||||
if store == nil || conversation == nil {
|
||||
return nil
|
||||
}
|
||||
path, err := cursorTranscriptPath(transcriptsFolder, conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := projectCursorTranscriptJSONLWithLatestStatus(conversation, includeLatestStatus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
data = preserveCursorAppendedTurnEnded(path, data)
|
||||
return writeCursorTranscriptAtomic(path, data)
|
||||
}
|
||||
|
||||
func (store *ConversationFileStore) SyncAllCursorTranscriptsBestEffort() {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
conversationIDs, err := store.ListConversationIDs()
|
||||
if err != nil {
|
||||
log.Printf("forwarder transcript backfill scan failed err=%v", err)
|
||||
return
|
||||
}
|
||||
for _, conversationID := range conversationIDs {
|
||||
conversation, err := store.LoadConversation(conversationID)
|
||||
if err != nil {
|
||||
log.Printf("forwarder transcript backfill load failed conversation_id=%s err=%v", conversationID, err)
|
||||
continue
|
||||
}
|
||||
if conversation == nil || conversation.AgentTranscriptsFolder == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(conversation.AgentTranscriptsFolder)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := store.syncCursorTranscriptWithLatestStatus(conversationID, conversation, conversation.AgentTranscriptsFolder, true); err != nil {
|
||||
log.Printf("forwarder transcript backfill failed conversation_id=%s err=%v", conversationID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contextVersionForEntries(entries []HistoryEntry) int64 {
|
||||
var version int64
|
||||
for _, entry := range entries {
|
||||
@@ -621,9 +690,21 @@ func appendEntriesInPlace(conversation *ConversationFile, entries []HistoryEntry
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
assigned := make([]HistoryEntry, 0, len(entries))
|
||||
existingIdempotencyKeys := make(map[string]struct{})
|
||||
for _, existing := range conversation.Entries {
|
||||
if key := strings.TrimSpace(existing.IdempotencyKey); key != "" {
|
||||
existingIdempotencyKeys[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
maxTurnSeq := conversation.NextTurnSeq - 1
|
||||
for _, entry := range entries {
|
||||
next := entry
|
||||
if key := strings.TrimSpace(next.IdempotencyKey); key != "" {
|
||||
if _, exists := existingIdempotencyKeys[key]; exists {
|
||||
continue
|
||||
}
|
||||
existingIdempotencyKeys[key] = struct{}{}
|
||||
}
|
||||
if next.CreatedAt.IsZero() {
|
||||
next.CreatedAt = now
|
||||
}
|
||||
@@ -663,6 +744,9 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
|
||||
target.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
||||
target.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
||||
target.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
||||
if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" {
|
||||
target.AgentTranscriptsFolder = folder
|
||||
}
|
||||
if strings.TrimSpace(source.Mode) != "" {
|
||||
target.Mode = strings.TrimSpace(source.Mode)
|
||||
}
|
||||
@@ -718,6 +802,10 @@ func normalizeLoadedConversation(conversationID string, conversation *Conversati
|
||||
if conversation.Entries == nil {
|
||||
conversation.Entries = make([]HistoryEntry, 0, 16)
|
||||
}
|
||||
conversation.AgentTranscriptsFolder = normalizeAgentTranscriptsFolder(conversation.AgentTranscriptsFolder)
|
||||
if conversation.AgentTranscriptsFolder == "" {
|
||||
conversation.AgentTranscriptsFolder = agentTranscriptsFolderFromEntries(conversation.Entries)
|
||||
}
|
||||
for _, entry := range conversation.Entries {
|
||||
if entry.Seq >= conversation.NextEntrySeq {
|
||||
conversation.NextEntrySeq = entry.Seq + 1
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendEntriesDeduplicatesIdempotencyKey(t *testing.T) {
|
||||
store := NewConversationFileStore(t.TempDir())
|
||||
entry := HistoryEntry{
|
||||
TurnSeq: 1,
|
||||
RequestID: "request-1",
|
||||
IdempotencyKey: "provider-interrupted-output:test",
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: json.RawMessage(`{"text":"partial"}`),
|
||||
}
|
||||
|
||||
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||
t.Fatalf("first AppendEntries() error = %v", err)
|
||||
} else if len(assigned) != 1 {
|
||||
t.Fatalf("first AppendEntries() assigned = %d, want 1", len(assigned))
|
||||
}
|
||||
if _, assigned, err := store.AppendEntries("conversation-1", []HistoryEntry{entry}); err != nil {
|
||||
t.Fatalf("duplicate AppendEntries() error = %v", err)
|
||||
} else if len(assigned) != 0 {
|
||||
t.Fatalf("duplicate AppendEntries() assigned = %d, want 0", len(assigned))
|
||||
}
|
||||
|
||||
conversation, err := store.LoadConversation("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
if len(conversation.Entries) != 1 {
|
||||
t.Fatalf("persisted entries = %d, want 1", len(conversation.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPersistsInterruptedProviderOutputIdempotently(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
stream.CurrentModelCallID = "model-call-1"
|
||||
stream.ProviderAccumulatedText = "partial answer"
|
||||
stream.ProviderAccumulatedReasoning = "partial reasoning"
|
||||
stream.mu.Unlock()
|
||||
|
||||
cancel := InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "[canceled] Superseded by newer request",
|
||||
}
|
||||
if err := service.handleCancelIntent(cancel); err != nil {
|
||||
t.Fatalf("first handleCancelIntent() error = %v", err)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
stream.ProviderAccumulatedText = "late duplicate fragment"
|
||||
stream.mu.Unlock()
|
||||
if err := service.handleCancelIntent(cancel); err != nil {
|
||||
t.Fatalf("duplicate handleCancelIntent() error = %v", err)
|
||||
}
|
||||
|
||||
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
assistantEntries := 0
|
||||
cancelEntries := 0
|
||||
for _, entry := range persisted.Entries {
|
||||
if entry.Kind == "metadata" {
|
||||
var payload metadataPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode metadata entry: %v", err)
|
||||
}
|
||||
if payload.Type == "control" && readStringValue(payload.Value["status"]) == "canceled" {
|
||||
cancelEntries++
|
||||
}
|
||||
}
|
||||
if entry.Kind != "assistant_text" {
|
||||
continue
|
||||
}
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode assistant entry: %v", err)
|
||||
}
|
||||
if payload.Text == "partial answer" {
|
||||
assistantEntries++
|
||||
}
|
||||
}
|
||||
if assistantEntries != 1 {
|
||||
t.Fatalf("persisted interrupted assistant entries = %d, want 1", assistantEntries)
|
||||
}
|
||||
if cancelEntries != 1 {
|
||||
t.Fatalf("persisted cancel metadata entries = %d, want 1", cancelEntries)
|
||||
}
|
||||
|
||||
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "partial answer" && strings.TrimSpace(message.ReasoningContent) == "partial reasoning" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("replay = %#v, want interrupted assistant output", replay)
|
||||
}
|
||||
checkpoint, err := service.projector.ProjectCheckpointProjection(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if checkpoint == nil || checkpoint.State == nil || len(checkpoint.State.GetTurns()) != 1 {
|
||||
t.Fatalf("checkpoint state = %#v, want interrupted turn", checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
|
||||
if err := service.handleCancelIntent(InboundIntent{
|
||||
Kind: "cancel",
|
||||
RequestID: stream.RequestID,
|
||||
CancelReason: "new_message_submitted",
|
||||
}); err != nil {
|
||||
t.Fatalf("handleCancelIntent() error = %v", err)
|
||||
}
|
||||
|
||||
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
replay, err := service.projector.ProjectPromptReplay(persisted)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "hi" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("replay = %#v, want persisted assistant activity", replay)
|
||||
}
|
||||
|
||||
func TestProjectPromptReplayPreservesLegacyCanceledTurnActivity(t *testing.T) {
|
||||
cancelEntry := newMetadataEntry(1, "request-1", "control", map[string]any{
|
||||
"status": "canceled",
|
||||
"reason": "new_message_submitted",
|
||||
"replay_policy": cancelReplayPolicyKeepStableInput,
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newAssistantTextEntry(1, "request-1", "persisted activity", "", ""),
|
||||
cancelEntry,
|
||||
},
|
||||
}
|
||||
|
||||
replay, err := NewHistoryProjector().ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Role == "assistant" && strings.TrimSpace(message.Content) == "persisted activity" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("replay = %#v, want legacy canceled activity", replay)
|
||||
}
|
||||
@@ -32,3 +32,11 @@ func NewModule(historyRoot string, channelService modeladapter.ChannelResolver)
|
||||
UploadServiceHandler: newUploadServiceHandler(service),
|
||||
}
|
||||
}
|
||||
|
||||
func (module *Module) HandlesAIPath(path string) bool {
|
||||
if module == nil || module.AiHandler == nil {
|
||||
return false
|
||||
}
|
||||
handler, ok := module.AiHandler.(interface{ HandlesPath(string) bool })
|
||||
return ok && handler.HandlesPath(path)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
|
||||
type HistoryProjector struct {
|
||||
}
|
||||
|
||||
type CheckpointBlob struct {
|
||||
ID []byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type CheckpointProjection struct {
|
||||
State *agentv1.ConversationStateStructure
|
||||
Blobs []CheckpointBlob
|
||||
}
|
||||
|
||||
type checkpointBlobGraph struct {
|
||||
blobs map[[sha256.Size]byte][]byte
|
||||
order [][sha256.Size]byte
|
||||
}
|
||||
|
||||
func newCheckpointBlobGraph() *checkpointBlobGraph {
|
||||
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
|
||||
}
|
||||
|
||||
func (graph *checkpointBlobGraph) add(data []byte) []byte {
|
||||
if graph == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
id := sha256.Sum256(data)
|
||||
if _, exists := graph.blobs[id]; !exists {
|
||||
graph.blobs[id] = append([]byte(nil), data...)
|
||||
graph.order = append(graph.order, id)
|
||||
}
|
||||
return append([]byte(nil), id[:]...)
|
||||
}
|
||||
|
||||
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
|
||||
if graph == nil || len(graph.order) == 0 {
|
||||
return nil
|
||||
}
|
||||
blobs := make([]CheckpointBlob, 0, len(graph.order))
|
||||
for _, id := range graph.order {
|
||||
blobs = append(blobs, CheckpointBlob{
|
||||
ID: append([]byte(nil), id[:]...),
|
||||
Data: append([]byte(nil), graph.blobs[id]...),
|
||||
})
|
||||
}
|
||||
return blobs
|
||||
}
|
||||
|
||||
// NewHistoryProjector 创建 history 投影器。
|
||||
func NewHistoryProjector() *HistoryProjector {
|
||||
return &HistoryProjector{}
|
||||
@@ -309,6 +355,7 @@ const (
|
||||
cancelReplayPolicyDropTurn = "drop_turn"
|
||||
cancelReplayPolicyDropUnstarted = "drop_unstarted_turn"
|
||||
cancelReplayPolicyKeepStableInput = "keep_stable_input"
|
||||
cancelReplayPolicyKeepInterrupted = "keep_interrupted_output"
|
||||
)
|
||||
|
||||
func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||
@@ -324,12 +371,18 @@ func sanitizeCanceledReplayEntries(entries []HistoryEntry) []HistoryEntry {
|
||||
for _, entry := range entries {
|
||||
if entry.TurnSeq > 0 {
|
||||
if policy, canceled := canceledTurns[entry.TurnSeq]; canceled {
|
||||
if policy == cancelReplayPolicyDropUnstarted {
|
||||
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
||||
policy = cancelReplayPolicyKeepStableInput
|
||||
} else {
|
||||
policy = cancelReplayPolicyDropTurn
|
||||
if policy == cancelReplayPolicyKeepInterrupted {
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
if policy != cancelReplayPolicyDropTurn {
|
||||
if _, active := activeCanceledTurns[entry.TurnSeq]; active {
|
||||
filtered = append(filtered, entry)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if policy == cancelReplayPolicyDropUnstarted {
|
||||
policy = cancelReplayPolicyDropTurn
|
||||
}
|
||||
if policy == cancelReplayPolicyDropTurn || !isStableCanceledTurnInputEntry(entry) {
|
||||
continue
|
||||
@@ -404,6 +457,8 @@ func normalizeCancelReplayPolicy(policy string, reason string) string {
|
||||
return cancelReplayPolicyDropUnstarted
|
||||
case cancelReplayPolicyKeepStableInput:
|
||||
return cancelReplayPolicyKeepStableInput
|
||||
case cancelReplayPolicyKeepInterrupted:
|
||||
return cancelReplayPolicyKeepInterrupted
|
||||
default:
|
||||
return cancelReplayPolicyForReason(reason)
|
||||
}
|
||||
@@ -475,6 +530,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
|
||||
|
||||
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
|
||||
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
|
||||
projection, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil || projection == nil {
|
||||
return nil, err
|
||||
}
|
||||
return projection.State, nil
|
||||
}
|
||||
|
||||
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
|
||||
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
|
||||
blobs := newCheckpointBlobGraph()
|
||||
state := &agentv1.ConversationStateStructure{
|
||||
TokenDetails: &agentv1.ConversationTokenDetails{
|
||||
UsedTokens: conversationTokenDetailsUsedTokens(conversation),
|
||||
@@ -488,7 +553,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
if conversation == nil {
|
||||
mode := agentv1.AgentMode_AGENT_MODE_AGENT
|
||||
state.Mode = &mode
|
||||
return state, nil
|
||||
return &CheckpointProjection{State: state}, nil
|
||||
}
|
||||
mode, err := parseModeAlias(conversation.Mode)
|
||||
if err != nil {
|
||||
@@ -506,158 +571,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
if structuredState.HasTodos {
|
||||
state.Todos = encodeConversationTodoBytes(structuredState.Todos)
|
||||
}
|
||||
grouped := make(map[int64][]HistoryEntry)
|
||||
order := make([]int64, 0, conversation.NextTurnSeq)
|
||||
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
||||
if entry.TurnSeq <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := grouped[entry.TurnSeq]; !ok {
|
||||
order = append(order, entry.TurnSeq)
|
||||
}
|
||||
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
||||
}
|
||||
|
||||
for _, turnSeq := range order {
|
||||
entries := grouped[turnSeq]
|
||||
var rawUserMessage []byte
|
||||
var turnRequestID string
|
||||
steps := make([][]byte, 0, len(entries))
|
||||
seenToolCalls := make(map[string]struct{})
|
||||
openToolCalls := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if turnRequestID == "" {
|
||||
turnRequestID = strings.TrimSpace(entry.RequestID)
|
||||
}
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
||||
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
||||
}
|
||||
payload, err := proto.Marshal(userMessage)
|
||||
turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawUserMessage = payload
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{
|
||||
ToolCall: toolCall,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
||||
seenToolCalls[toolCallID] = struct{}{}
|
||||
openToolCalls[toolCallID] = struct{}{}
|
||||
}
|
||||
case "tool_result":
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
|
||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||
delete(openToolCalls, toolCallID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
if len(payload.ToolCall) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
|
||||
continue
|
||||
}
|
||||
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{
|
||||
ToolCall: toolCall,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, stepPayload)
|
||||
}
|
||||
}
|
||||
if len(rawUserMessage) == 0 && len(steps) == 0 {
|
||||
continue
|
||||
}
|
||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: rawUserMessage,
|
||||
Steps: steps,
|
||||
}
|
||||
if turnRequestID != "" {
|
||||
agentTurn.RequestId = &turnRequestID
|
||||
}
|
||||
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: agentTurn,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Turns = append(state.Turns, turnPayload)
|
||||
}
|
||||
state.Turns = turnIDs
|
||||
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -685,16 +603,213 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
|
||||
return nil, err
|
||||
}
|
||||
state.RootPromptMessagesJson = rootPromptMessages
|
||||
return state, nil
|
||||
return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
|
||||
}
|
||||
|
||||
func marshalThinkingStep(text string) ([]byte, error) {
|
||||
return proto.Marshal(&agentv1.ConversationStep{
|
||||
func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
|
||||
if conversation == nil || blobs == nil {
|
||||
return nil, nil
|
||||
}
|
||||
grouped := make(map[int64][]HistoryEntry)
|
||||
order := make([]int64, 0, conversation.NextTurnSeq)
|
||||
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
|
||||
if entry.TurnSeq <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := grouped[entry.TurnSeq]; !ok {
|
||||
order = append(order, entry.TurnSeq)
|
||||
}
|
||||
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
|
||||
}
|
||||
logicalTurns := make([][]HistoryEntry, 0, len(order))
|
||||
for _, turnSeq := range order {
|
||||
entries := grouped[turnSeq]
|
||||
if checkpointTurnHasUserMessage(entries) {
|
||||
logicalTurns = append(logicalTurns, append([]HistoryEntry(nil), entries...))
|
||||
continue
|
||||
}
|
||||
if len(logicalTurns) == 0 {
|
||||
continue
|
||||
}
|
||||
last := len(logicalTurns) - 1
|
||||
logicalTurns[last] = append(logicalTurns[last], entries...)
|
||||
}
|
||||
|
||||
turnIDs := make([][]byte, 0, len(logicalTurns))
|
||||
for _, entries := range logicalTurns {
|
||||
completedToolCalls, err := collectCheckpointCompletedToolCalls(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var userMessageID []byte
|
||||
var turnRequestID string
|
||||
steps := make([]*agentv1.ConversationStep, 0, len(entries))
|
||||
seenToolCalls := make(map[string]struct{})
|
||||
openToolCalls := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
if turnRequestID == "" {
|
||||
turnRequestID = strings.TrimSpace(entry.RequestID)
|
||||
}
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
|
||||
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
|
||||
}
|
||||
payload, err := proto.Marshal(userMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userMessageID = blobs.add(payload)
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: text},
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(payload.Text) == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_AssistantMessage{
|
||||
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
|
||||
},
|
||||
})
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedPayload := completedToolCalls[toolCallID]; len(completedPayload) > 0 {
|
||||
completedToolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(completedPayload, completedToolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proto.Merge(toolCall, completedToolCall)
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||
})
|
||||
if toolCallID != "" {
|
||||
seenToolCalls[toolCallID] = struct{}{}
|
||||
openToolCalls[toolCallID] = struct{}{}
|
||||
}
|
||||
case "tool_result":
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toolCallID := strings.TrimSpace(payload.ToolCallID)
|
||||
if toolCallID != "" {
|
||||
delete(openToolCalls, toolCallID)
|
||||
}
|
||||
if _, ok := seenToolCalls[toolCallID]; ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(payload.ReasoningContent) != "" {
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ThinkingMessage{
|
||||
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(payload.ToolCall) == 0 {
|
||||
continue
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
steps = append(steps, &agentv1.ConversationStep{
|
||||
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(userMessageID) == 0 {
|
||||
continue
|
||||
}
|
||||
stepIDs := make([][]byte, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
stepID, err := addCheckpointStepBlob(blobs, step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stepIDs = append(stepIDs, stepID)
|
||||
}
|
||||
agentTurn := &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: userMessageID,
|
||||
Steps: stepIDs,
|
||||
}
|
||||
if turnRequestID != "" {
|
||||
agentTurn.RequestId = &turnRequestID
|
||||
}
|
||||
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: agentTurn,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
turnIDs = append(turnIDs, blobs.add(turnPayload))
|
||||
}
|
||||
return turnIDs, nil
|
||||
}
|
||||
|
||||
func checkpointTurnHasUserMessage(entries []HistoryEntry) bool {
|
||||
for _, entry := range entries {
|
||||
if strings.TrimSpace(entry.Kind) == "user_message" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectCheckpointCompletedToolCalls(entries []HistoryEntry) (map[string]json.RawMessage, error) {
|
||||
completed := make(map[string]json.RawMessage)
|
||||
for _, entry := range entries {
|
||||
if strings.TrimSpace(entry.Kind) != "tool_result" {
|
||||
continue
|
||||
}
|
||||
var payload toolResultEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" && len(payload.ToolCall) > 0 {
|
||||
completed[toolCallID] = payload.ToolCall
|
||||
}
|
||||
}
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
|
||||
payload, err := proto.Marshal(step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blobs.add(payload), nil
|
||||
}
|
||||
|
||||
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
|
||||
if conversation == nil {
|
||||
@@ -1132,7 +1247,7 @@ func trimReplayDanglingAssistantToolCalls(messages []modeladapter.Message) []mod
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func shouldPersistToolResultName(toolName string) bool {
|
||||
func shouldPersistCheckpointReplayToolResultName(toolName string) bool {
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case "PatchEdit", "PatchEditLines", "PatchEditSpan", "Edit", "Write", "GenerateImage":
|
||||
return true
|
||||
@@ -1141,60 +1256,6 @@ func shouldPersistToolResultName(toolName string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func filterCheckpointTurns(rawTurns [][]byte) [][]byte {
|
||||
if len(rawTurns) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([][]byte, 0, len(rawTurns))
|
||||
for _, rawTurn := range rawTurns {
|
||||
if len(rawTurn) == 0 {
|
||||
continue
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
|
||||
nextSteps := make([][]byte, 0, len(agentTurn.GetSteps()))
|
||||
for _, rawStep := range agentTurn.GetSteps() {
|
||||
if len(rawStep) == 0 {
|
||||
continue
|
||||
}
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
||||
continue
|
||||
}
|
||||
if toolCall := step.GetToolCall(); toolCall != nil && !shouldPersistToolResultName(inferToolName(toolCall)) {
|
||||
continue
|
||||
}
|
||||
nextSteps = append(nextSteps, append([]byte(nil), rawStep...))
|
||||
}
|
||||
if len(agentTurn.GetUserMessage()) == 0 && len(nextSteps) == 0 {
|
||||
continue
|
||||
}
|
||||
encoded, err := proto.Marshal(&agentv1.ConversationTurnStructure{
|
||||
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
|
||||
AgentConversationTurn: &agentv1.AgentConversationTurnStructure{
|
||||
UserMessage: append([]byte(nil), agentTurn.GetUserMessage()...),
|
||||
Steps: nextSteps,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
filtered = append(filtered, append([]byte(nil), rawTurn...))
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, encoded)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []promptengine.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
@@ -1205,7 +1266,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
||||
if strings.TrimSpace(message.Role) == "assistant" && len(message.ToolCalls) > 0 {
|
||||
nextToolCalls := make([]promptengine.ToolCallDescriptor, 0, len(message.ToolCalls))
|
||||
for _, toolCall := range message.ToolCalls {
|
||||
if !shouldPersistToolResultName(toolCall.Function.Name) {
|
||||
if !shouldPersistCheckpointReplayToolResultName(toolCall.Function.Name) {
|
||||
skippedToolCallIDs[strings.TrimSpace(toolCall.ID)] = struct{}{}
|
||||
continue
|
||||
}
|
||||
@@ -1222,7 +1283,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
||||
if _, ok := skippedToolCallIDs[strings.TrimSpace(message.ToolCallID)]; ok {
|
||||
continue
|
||||
}
|
||||
if !shouldPersistToolResultName(message.Name) {
|
||||
if !shouldPersistCheckpointReplayToolResultName(message.Name) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
)
|
||||
|
||||
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
|
||||
Text: "parent question",
|
||||
MessageId: "message-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
state := projection.State
|
||||
if len(state.GetTurns()) != 1 {
|
||||
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob-backed turn", len(state.GetTurns()))
|
||||
}
|
||||
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
digest := sha256.Sum256(blob.Data)
|
||||
if len(blob.ID) != sha256.Size || string(blob.ID) != string(digest[:]) {
|
||||
t.Fatalf("invalid content-addressed Blob id=%x", blob.ID)
|
||||
}
|
||||
blobs[string(blob.ID)] = blob.Data
|
||||
}
|
||||
turnPayload, ok := blobs[string(state.GetTurns()[0])]
|
||||
if !ok {
|
||||
t.Fatal("turn references a missing Blob")
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(turnPayload, turn); err != nil {
|
||||
t.Fatalf("decode turn Blob: %v", err)
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
t.Fatal("turn Blob does not contain an agent turn")
|
||||
}
|
||||
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
|
||||
t.Fatal("turn references a missing user message Blob")
|
||||
}
|
||||
for _, stepID := range agentTurn.GetSteps() {
|
||||
if _, ok := blobs[string(stepID)]; !ok {
|
||||
t.Fatal("turn references a missing step Blob")
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := importedConversationStateModelMessages(state)
|
||||
if err != nil {
|
||||
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages))
|
||||
}
|
||||
if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") {
|
||||
t.Fatalf("first imported message = %#v", messages[0])
|
||||
}
|
||||
if messages[1].Role != "assistant" || messages[1].Content != "parent answer" {
|
||||
t.Fatalf("second imported message = %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionMergesResumeActivityIntoPreviousUserTurn(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
|
||||
Text: "original question",
|
||||
MessageId: "message-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
firstAnswer := newAssistantTextEntry(1, "request-1", "before resume", "", "")
|
||||
firstAnswer.Seq = 2
|
||||
resumedAnswer := newAssistantTextEntry(2, "request-resume", "after resume", "", "")
|
||||
resumedAnswer.Seq = 3
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 3,
|
||||
NextEntrySeq: 4,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
firstAnswer,
|
||||
resumedAnswer,
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(projection.State.GetTurns()) != 1 {
|
||||
t.Fatalf("checkpoint turns = %d, want one logical user turn", len(projection.State.GetTurns()))
|
||||
}
|
||||
|
||||
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
blobs[string(blob.ID)] = blob.Data
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(blobs[string(projection.State.GetTurns()[0])], turn); err != nil {
|
||||
t.Fatalf("decode checkpoint turn: %v", err)
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
t.Fatal("checkpoint turn does not contain an agent turn")
|
||||
}
|
||||
if len(agentTurn.GetUserMessage()) == 0 {
|
||||
t.Fatal("checkpoint turn lost the original user message Blob id")
|
||||
}
|
||||
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
|
||||
t.Fatal("checkpoint turn references a missing user message Blob")
|
||||
}
|
||||
if agentTurn.GetRequestId() != "request-1" {
|
||||
t.Fatalf("checkpoint request id = %q, want original request", agentTurn.GetRequestId())
|
||||
}
|
||||
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 2 || steps[0].GetAssistantMessage().GetText() != "before resume" || steps[1].GetAssistantMessage().GetText() != "after resume" {
|
||||
t.Fatalf("checkpoint steps did not preserve resumed activity: %#v", steps)
|
||||
}
|
||||
|
||||
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||
if err != nil {
|
||||
t.Fatalf("decode root prompt replay: %v", err)
|
||||
}
|
||||
if len(replay) != 3 || replay[0].Role != "user" || replay[1].Content != "before resume" || replay[2].Content != "after resume" {
|
||||
t.Fatalf("checkpoint turn merge changed model replay: %#v", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionOmitsActivityWithoutAnyUserMessage(t *testing.T) {
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
newAssistantTextEntry(1, "request-resume", "orphaned resume output", "", ""),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(projection.State.GetTurns()) != 0 || len(projection.Blobs) != 0 {
|
||||
t.Fatalf("checkpoint emitted a turn without a user message: %#v", projection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
|
||||
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal first user message: %v", err)
|
||||
}
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
NextEntrySeq: 3,
|
||||
TokenDetailsMaxTokens: projectedConversationMaxTokens,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: firstUser},
|
||||
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||
},
|
||||
}
|
||||
projector := NewHistoryProjector()
|
||||
midpoint, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("midpoint projection: %v", err)
|
||||
}
|
||||
|
||||
secondUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "second question", MessageId: "message-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal second user message: %v", err)
|
||||
}
|
||||
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||
{TurnSeq: 2, RequestID: "request-2", Role: "user", Kind: "user_message", Payload: secondUser},
|
||||
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||
})
|
||||
latest, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("latest projection: %v", err)
|
||||
}
|
||||
|
||||
midpointMessages, err := importedConversationStateModelMessages(midpoint.State)
|
||||
if err != nil {
|
||||
t.Fatalf("import midpoint messages: %v", err)
|
||||
}
|
||||
latestMessages, err := importedConversationStateModelMessages(latest.State)
|
||||
if err != nil {
|
||||
t.Fatalf("import latest messages: %v", err)
|
||||
}
|
||||
if len(midpoint.State.GetTurns()) != 1 || len(midpointMessages) != 2 {
|
||||
t.Fatalf("midpoint turns=%d messages=%d, want 1 turn and 2 messages", len(midpoint.State.GetTurns()), len(midpointMessages))
|
||||
}
|
||||
if len(latest.State.GetTurns()) != 2 || len(latestMessages) != 4 {
|
||||
t.Fatalf("latest turns=%d messages=%d, want 2 turns and 4 messages", len(latest.State.GetTurns()), len(latestMessages))
|
||||
}
|
||||
if midpointMessages[1].Content != "first answer" || latestMessages[3].Content != "second answer" {
|
||||
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionMergesToolCallWithCompletedResult(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
startedAt := uint64(100)
|
||||
toolCallID := "call-1"
|
||||
startedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
ToolCallId: &toolCallID,
|
||||
StartedAtMs: &startedAt,
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||
},
|
||||
},
|
||||
})
|
||||
completedAt := uint64(200)
|
||||
completedToolCall := checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
CompletedAtMs: &completedAt,
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Result: &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
Path: "/tmp/example.txt",
|
||||
TotalLines: 1,
|
||||
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newAssistantTextEntry(1, "request-1", "before", "", ""),
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||
newAssistantTextEntry(1, "request-1", "after", "", ""),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(projection.Blobs) != 5 {
|
||||
t.Fatalf("checkpoint blobs = %d, want user, three final steps, and turn", len(projection.Blobs))
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("checkpoint steps = %d, want assistant, completed Read, assistant", len(steps))
|
||||
}
|
||||
if steps[0].GetAssistantMessage().GetText() != "before" || steps[2].GetAssistantMessage().GetText() != "after" {
|
||||
t.Fatalf("checkpoint step ordering changed: %#v", steps)
|
||||
}
|
||||
mergedToolCall := steps[1].GetToolCall()
|
||||
readCall := mergedToolCall.GetReadToolCall()
|
||||
if readCall == nil || readCall.GetResult().GetSuccess().GetContent() != "file contents" {
|
||||
t.Fatalf("checkpoint Read step does not contain completed result: %#v", steps[1].GetToolCall())
|
||||
}
|
||||
if readCall.GetArgs().GetPath() != "/tmp/example.txt" || mergedToolCall.GetToolCallId() != toolCallID || mergedToolCall.GetStartedAtMs() != startedAt || mergedToolCall.GetCompletedAtMs() != completedAt {
|
||||
t.Fatalf("checkpoint Read step lost started-call fields: %#v", mergedToolCall)
|
||||
}
|
||||
|
||||
replay, err := promptengine.DecodeReplayMessages(projection.State.GetRootPromptMessagesJson())
|
||||
if err != nil {
|
||||
t.Fatalf("decode root prompt replay: %v", err)
|
||||
}
|
||||
for _, message := range replay {
|
||||
if message.Name == "Read" || len(message.ToolCalls) > 0 {
|
||||
t.Fatalf("UI-only Read result leaked into root prompt replay: %#v", replay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionIsIdempotentAndDoesNotMutateHistory(t *testing.T) {
|
||||
userPayload, err := protojson.Marshal(&agentv1.UserMessage{Text: "inspect file", MessageId: "message-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Success{
|
||||
Success: &agentv1.ReadToolSuccess{
|
||||
Path: "/tmp/example.txt",
|
||||
TotalLines: 1,
|
||||
Output: &agentv1.ReadToolSuccess_Content{Content: "file contents"},
|
||||
},
|
||||
},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", completedToolCall),
|
||||
},
|
||||
}
|
||||
before, err := json.Marshal(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation before projection: %v", err)
|
||||
}
|
||||
|
||||
projector := NewHistoryProjector()
|
||||
first, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("first projection: %v", err)
|
||||
}
|
||||
second, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("second projection: %v", err)
|
||||
}
|
||||
|
||||
if !proto.Equal(first.State, second.State) {
|
||||
t.Fatalf("repeated projection changed checkpoint state: first=%#v second=%#v", first.State, second.State)
|
||||
}
|
||||
if len(first.Blobs) != len(second.Blobs) {
|
||||
t.Fatalf("repeated projection changed blob count: first=%d second=%d", len(first.Blobs), len(second.Blobs))
|
||||
}
|
||||
for index := range first.Blobs {
|
||||
if !bytes.Equal(first.Blobs[index].ID, second.Blobs[index].ID) || !bytes.Equal(first.Blobs[index].Data, second.Blobs[index].Data) {
|
||||
t.Fatalf("repeated projection changed blob %d", index)
|
||||
}
|
||||
}
|
||||
after, err := json.Marshal(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal conversation after projection: %v", err)
|
||||
}
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatalf("checkpoint projection mutated semantic history:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionKeepsStartedToolCallWhenResultPayloadIsMissing(t *testing.T) {
|
||||
startedToolCall := checkpointTestReadToolCall(t, nil)
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
testCheckpointUserEntry(t),
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", startedToolCall),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "read failed", "", nil),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("checkpoint steps = %d, want the original Read step", len(steps))
|
||||
}
|
||||
readCall := steps[0].GetToolCall().GetReadToolCall()
|
||||
if readCall == nil || readCall.GetArgs().GetPath() != "/tmp/example.txt" || readCall.GetResult() != nil {
|
||||
t.Fatalf("checkpoint did not preserve the original Read call: %#v", steps[0].GetToolCall())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCheckpointProjectionAppendsLegacyResultWithoutToolCallEntry(t *testing.T) {
|
||||
completedToolCall := checkpointTestReadToolCall(t, &agentv1.ReadToolResult{
|
||||
Result: &agentv1.ReadToolResult_Error{Error: &agentv1.ReadToolError{ErrorMessage: "not readable"}},
|
||||
})
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 2,
|
||||
Entries: []HistoryEntry{
|
||||
testCheckpointUserEntry(t),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "not readable", "", completedToolCall),
|
||||
},
|
||||
}
|
||||
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
steps := checkpointProjectionSteps(t, projection)
|
||||
if len(steps) != 1 || steps[0].GetToolCall().GetReadToolCall().GetResult().GetError().GetErrorMessage() != "not readable" {
|
||||
t.Fatalf("legacy result-only Read step was not preserved: %#v", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func checkpointTestReadToolCall(t *testing.T, result *agentv1.ReadToolResult) []byte {
|
||||
t.Helper()
|
||||
return checkpointTestToolCallPayload(t, &agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_ReadToolCall{
|
||||
ReadToolCall: &agentv1.ReadToolCall{
|
||||
Args: &agentv1.ReadToolArgs{Path: "/tmp/example.txt"},
|
||||
Result: result,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func checkpointTestToolCallPayload(t *testing.T, toolCall *agentv1.ToolCall) []byte {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(toolCall)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Read tool call: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func checkpointProjectionSteps(t *testing.T, projection *CheckpointProjection) []*agentv1.ConversationStep {
|
||||
t.Helper()
|
||||
if projection == nil || projection.State == nil || len(projection.State.GetTurns()) != 1 {
|
||||
t.Fatalf("checkpoint turns = %#v, want exactly one turn", projection)
|
||||
}
|
||||
blobs := make(map[string][]byte, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
blobs[string(blob.ID)] = blob.Data
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(blobs[string(projection.State.GetTurns()[0])], turn); err != nil {
|
||||
t.Fatalf("decode checkpoint turn: %v", err)
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
t.Fatal("checkpoint turn does not contain an agent turn")
|
||||
}
|
||||
steps := make([]*agentv1.ConversationStep, 0, len(agentTurn.GetSteps()))
|
||||
for _, stepID := range agentTurn.GetSteps() {
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(blobs[string(stepID)], step); err != nil {
|
||||
t.Fatalf("decode checkpoint step: %v", err)
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
)
|
||||
|
||||
const promptContextSourceSelectedCursorCommands = "selected_cursor_commands"
|
||||
|
||||
func newPromptContextMessage(source string, message modeladapter.Message, persist bool) PromptContextMessage {
|
||||
context := PromptContextMessage{
|
||||
Source: strings.TrimSpace(source),
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
promptGuardSelectedFileChars = 16000
|
||||
promptGuardSelectedFilesTotalChars = 64000
|
||||
promptGuardSelectedFilesMaxCount = 12
|
||||
promptGuardCursorCommandNameChars = 256
|
||||
promptGuardCursorCommandChars = 12000
|
||||
promptGuardCursorCommandsTotalChars = 32000
|
||||
promptGuardCursorCommandsMaxCount = 8
|
||||
promptGuardRequestFileChars = 16000
|
||||
promptGuardRequestFilesTotalChars = 64000
|
||||
promptGuardRequestFilesMaxCount = 12
|
||||
@@ -106,11 +110,42 @@ func guardSelectedContext(selectedContext *agentv1.SelectedContext) *agentv1.Sel
|
||||
return selectedContext
|
||||
}
|
||||
cloned.Files = guardSelectedFiles(cloned.GetFiles())
|
||||
cloned.CursorCommands = guardSelectedCursorCommands(cloned.GetCursorCommands())
|
||||
cloned.SelectedSkills = guardAgentSkills(cloned.GetSelectedSkills())
|
||||
cloned.ExtraContext = guardStringSlice(cloned.GetExtraContext(), "selected_context.extra_context", promptGuardRealtimeTextChars, promptGuardRealtimeTextChars, promptGuardAgentSkillsMaxCount)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func guardSelectedCursorCommands(commands []*agentv1.SelectedCursorCommand) []*agentv1.SelectedCursorCommand {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*agentv1.SelectedCursorCommand, 0, minInt(len(commands), promptGuardCursorCommandsMaxCount))
|
||||
remaining := promptGuardCursorCommandsTotalChars
|
||||
for _, command := range commands {
|
||||
if command == nil || len(result) >= promptGuardCursorCommandsMaxCount {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(command.GetContent())
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
limit := minInt(promptGuardCursorCommandChars, remaining)
|
||||
if limit <= 0 {
|
||||
break
|
||||
}
|
||||
cloned, ok := proto.Clone(command).(*agentv1.SelectedCursorCommand)
|
||||
if !ok || cloned == nil {
|
||||
continue
|
||||
}
|
||||
cloned.Name = truncatePromptGuardText("selected_context.cursor_commands.name", strings.TrimSpace(cloned.GetName()), promptGuardCursorCommandNameChars)
|
||||
cloned.Content = truncatePromptGuardText("selected_context.cursor_commands.content", content, limit)
|
||||
remaining -= promptGuardRuneCount(cloned.GetContent())
|
||||
result = append(result, cloned)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func guardSelectedFiles(files []*agentv1.SelectedFile) []*agentv1.SelectedFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -260,6 +260,9 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
|
||||
conversation.ParentConversationID = strings.TrimSpace(source.ParentConversationID)
|
||||
conversation.ParentToolCallID = strings.TrimSpace(source.ParentToolCallID)
|
||||
conversation.SubagentTypeName = strings.TrimSpace(source.SubagentTypeName)
|
||||
if folder := normalizeAgentTranscriptsFolder(source.AgentTranscriptsFolder); folder != "" {
|
||||
conversation.AgentTranscriptsFolder = folder
|
||||
}
|
||||
if strings.TrimSpace(source.Mode) != "" {
|
||||
conversation.Mode = strings.TrimSpace(source.Mode)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package forwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,6 +25,7 @@ import (
|
||||
interactionbridge "cursor/internal/backend/agent/bridge/interaction"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
protocol "cursor/internal/backend/agent/protocol"
|
||||
)
|
||||
|
||||
@@ -301,6 +304,7 @@ func NewService(historyRoot string, resolver modeladapter.ChannelResolver) *Serv
|
||||
appendSeq: newAppendSequenceTracker(),
|
||||
}
|
||||
service.startHistoryMaintenance()
|
||||
store.SyncAllCursorTranscriptsBestEffort()
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -672,6 +676,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
|
||||
default:
|
||||
return InboundIntent{}, fmt.Errorf("unsupported client message kind: %s", clientKind)
|
||||
}
|
||||
intent.ManualCompaction = resolveInboundManualCompaction(message, intent.UserMessage)
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
@@ -689,6 +694,11 @@ func (service *Service) handleRunIntent(intent InboundIntent) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if intent.RequestContext != nil {
|
||||
if folder := normalizeAgentTranscriptsFolder(intent.RequestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" {
|
||||
conversation.AgentTranscriptsFolder = folder
|
||||
}
|
||||
}
|
||||
rewindDecision := service.decideRunRewind(intent, conversation)
|
||||
if rewindDecision.Evaluated && !rewindDecision.Apply {
|
||||
service.logRunRewindDecision(intent.RequestID, intent.ConversationID, "rewind_skipped", rewindDecision)
|
||||
@@ -751,6 +761,7 @@ func (service *Service) handleRunIntent(intent InboundIntent) error {
|
||||
stream.mu.Lock()
|
||||
stream.ThinkingEffort = strings.TrimSpace(intent.ThinkingEffort)
|
||||
stream.SubagentModelOverrides = cloneSubagentModelOverrides(intent.SubagentModelOverrides)
|
||||
stream.ManualCompaction = intent.ManualCompaction
|
||||
stream.PendingProviderAction = providerActionNone
|
||||
stream.PendingCompaction = nil
|
||||
stream.PendingExecs = make(map[string]runtimecore.PendingExec)
|
||||
@@ -788,6 +799,7 @@ func (service *Service) handleRunIntent(intent InboundIntent) error {
|
||||
"subagent_model_override_count": len(intent.SubagentModelOverrides),
|
||||
"subagent_model_overrides": subagentModelOverrideSummaries(intent.SubagentModelOverrides),
|
||||
"latest_user_text": userMessageText(intent.UserMessage),
|
||||
"manual_compaction_requested": intent.ManualCompaction.Requested,
|
||||
})
|
||||
if err := service.publishCheckpoint(intent.RequestID, intent.ConversationID); err != nil {
|
||||
return err
|
||||
@@ -824,14 +836,22 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
}
|
||||
hasCheckpoint := checkpointConversationInitialized(stream)
|
||||
if hasCheckpoint {
|
||||
preservedInterruptedOutput, err := service.persistInterruptedProviderOutput(stream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cancelReason := firstNonEmpty(intent.CancelReason, "user aborted")
|
||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||
newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||
replayPolicy := cancelReplayPolicyForReason(cancelReason)
|
||||
if preservedInterruptedOutput || checkpointTurnHasReplayActivity(stream) {
|
||||
replayPolicy = cancelReplayPolicyKeepInterrupted
|
||||
}
|
||||
cancelEntry := newMetadataEntry(stream.TurnSeq, intent.RequestID, "control", map[string]any{
|
||||
"status": "canceled",
|
||||
"reason": cancelReason,
|
||||
"replay_policy": cancelReplayPolicyForReason(cancelReason),
|
||||
}),
|
||||
"replay_policy": replayPolicy,
|
||||
})
|
||||
cancelEntry.IdempotencyKey = cancelMetadataIdempotencyKey(stream.TurnSeq, intent.RequestID)
|
||||
_, err = service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{cancelEntry})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -848,9 +868,7 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
})
|
||||
}
|
||||
if hasCheckpoint {
|
||||
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
service.discardPendingCheckpoint(stream, "checkpoint superseded by cancellation")
|
||||
}
|
||||
clearPendingProviderCompletion(stream)
|
||||
stream.mu.Lock()
|
||||
@@ -861,6 +879,89 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
|
||||
return service.broker.Cancel(intent.RequestID, firstNonEmpty(intent.CancelReason, "[canceled] User aborted request"))
|
||||
}
|
||||
|
||||
func checkpointTurnHasReplayActivity(stream *ActiveStream) bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
if stream.CheckpointConversation == nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range stream.CheckpointConversation.Entries {
|
||||
if entry.TurnSeq == stream.TurnSeq && isCanceledTurnActivityEntry(entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// persistInterruptedProviderOutput commits the current provider pass before cancellation.
|
||||
// The entry key is stable for this provider pass, so repeated cancellation handling is a no-op.
|
||||
func (service *Service) persistInterruptedProviderOutput(stream *ActiveStream) (bool, error) {
|
||||
if stream == nil {
|
||||
return false, nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
turnSeq := stream.TurnSeq
|
||||
requestID := strings.TrimSpace(stream.RequestID)
|
||||
modelCallID := strings.TrimSpace(stream.CurrentModelCallID)
|
||||
providerPass := stream.ProviderPassCount
|
||||
text := stream.ProviderAccumulatedText
|
||||
reasoning := stream.ProviderAccumulatedReasoning
|
||||
reasoningSignature := stream.ProviderAccumulatedReasoningSignature
|
||||
reasoningSignatureSource := stream.ProviderAccumulatedReasoningSignatureSource
|
||||
reasoningItemID := stream.ProviderAccumulatedReasoningItemID
|
||||
reasoningStatus := stream.ProviderAccumulatedReasoningStatus
|
||||
reasoningSummary := append([]byte(nil), stream.ProviderAccumulatedReasoningSummary...)
|
||||
stream.mu.Unlock()
|
||||
if strings.TrimSpace(text) == "" && !hasReplayableReasoningPayload(reasoning, reasoningSignature, reasoningSignatureSource) {
|
||||
return false, nil
|
||||
}
|
||||
key := interruptedProviderOutputIdempotencyKey(turnSeq, requestID, modelCallID, providerPass)
|
||||
_, err := service.appendConversationEntries(stream, stream.ConversationID, []HistoryEntry{
|
||||
{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: requestID,
|
||||
IdempotencyKey: key,
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: newAssistantTextPayload(
|
||||
text,
|
||||
reasoning,
|
||||
reasoningSignature,
|
||||
reasoningSignatureSource,
|
||||
reasoningItemID,
|
||||
reasoningStatus,
|
||||
reasoningSummary,
|
||||
),
|
||||
},
|
||||
})
|
||||
return true, err
|
||||
}
|
||||
|
||||
func interruptedProviderOutputIdempotencyKey(turnSeq int64, requestID string, modelCallID string, providerPass int) string {
|
||||
payload := strings.Join([]string{
|
||||
"provider_interrupted_output",
|
||||
fmt.Sprintf("%d", turnSeq),
|
||||
strings.TrimSpace(requestID),
|
||||
strings.TrimSpace(modelCallID),
|
||||
fmt.Sprintf("%d", providerPass),
|
||||
}, "\x00")
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "provider-interrupted-output:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func cancelMetadataIdempotencyKey(turnSeq int64, requestID string) string {
|
||||
payload := strings.Join([]string{
|
||||
"cancel",
|
||||
fmt.Sprintf("%d", turnSeq),
|
||||
strings.TrimSpace(requestID),
|
||||
}, "\x00")
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "cancel:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// handleExecResult 处理客户端返回的执行桥结果,并在终态时把 tool_result 写回 history。
|
||||
func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||
stream, ok := service.broker.Get(intent.RequestID)
|
||||
@@ -902,6 +1003,11 @@ func (service *Service) handleExecResult(intent InboundIntent) error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if message := buildShellToolCallDeltaMessage(pending.ToolCallID, pending.ModelCallID, result.ShellOutputDelta); message != nil {
|
||||
if err := service.broker.Publish(intent.RequestID, StreamEvent{Message: message}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if !result.IsTerminal {
|
||||
return nil
|
||||
@@ -1635,6 +1741,13 @@ func (service *Service) handleToolInvocation(stream *ActiveStream, invocation ru
|
||||
stream.ToolInvocationCount++
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if !isKnownToolName(trimmedToolName) {
|
||||
displayToolName := trimmedToolName
|
||||
if displayToolName == "" {
|
||||
displayToolName = "<empty>"
|
||||
}
|
||||
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("Model hallucination: attempted to invoke a nonexistent tool: %s", displayToolName))
|
||||
}
|
||||
if !isToolAllowedInMode(mode, subagentTypeName, trimmedToolName) {
|
||||
return service.completePreDispatchToolError(stream, invocation, nil, false, false, fmt.Errorf("tool invocation is not enabled in mode %s: %s", mode.String(), invocation.ToolName))
|
||||
}
|
||||
@@ -2112,9 +2225,15 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := service.publishCheckpoint(requestID, conversationID); err != nil {
|
||||
return err
|
||||
return service.publishCheckpointWithCompletion(requestID, conversationID, &completion)
|
||||
}
|
||||
|
||||
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
|
||||
usage := completion.Usage
|
||||
if err := service.broker.Publish(requestID, StreamEvent{
|
||||
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
|
||||
}); err != nil {
|
||||
@@ -2141,7 +2260,11 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
|
||||
}
|
||||
|
||||
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
|
||||
func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
||||
func (service *Service) publishCheckpoint(requestID string, conversationID string) error {
|
||||
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
|
||||
}
|
||||
|
||||
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
|
||||
stream, ok := service.broker.Get(requestID)
|
||||
if !ok || stream == nil {
|
||||
return fmt.Errorf("request is not active: %s", requestID)
|
||||
@@ -2150,15 +2273,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := service.projector.ProjectLegacyCheckpoint(conversation)
|
||||
projection, err := service.projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state)
|
||||
return service.broker.Publish(requestID, StreamEvent{
|
||||
Message: buildCheckpointMessage(state),
|
||||
})
|
||||
if projection == nil || projection.State == nil {
|
||||
return fmt.Errorf("checkpoint projection is empty")
|
||||
}
|
||||
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
|
||||
return service.queueCheckpointProjection(stream, projection, completion)
|
||||
}
|
||||
|
||||
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
||||
@@ -2332,7 +2456,8 @@ func buildRunEntries(intent InboundIntent, effectiveMode agentv1.AgentMode, turn
|
||||
}
|
||||
}
|
||||
if intent.UserMessage != nil {
|
||||
payload, err := protojson.Marshal(normalizeUserMessageForStorage(intent.UserMessage))
|
||||
normalized := normalizeUserMessageForStorage(intent.UserMessage)
|
||||
payload, err := protojson.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2343,6 +2468,13 @@ func buildRunEntries(intent InboundIntent, effectiveMode agentv1.AgentMode, turn
|
||||
Kind: "user_message",
|
||||
Payload: payload,
|
||||
})
|
||||
if commandMessage, ok := promptengine.BuildSelectedCursorCommandsReplayMessage(normalized); ok {
|
||||
entries = append(entries, newPromptContextEntry(turnSeq, intent.RequestID, newPromptContextMessage(
|
||||
promptContextSourceSelectedCursorCommands,
|
||||
modeladapter.Message{Role: commandMessage.Role, Content: commandMessage.Content},
|
||||
true,
|
||||
)))
|
||||
}
|
||||
}
|
||||
modeEntry, err := newModeMetadataEntry(turnSeq, intent.RequestID, effectiveMode, intent.HasExplicitMode, intent.ModeSource)
|
||||
if err != nil {
|
||||
@@ -2404,6 +2536,16 @@ func newAssistantTextEntry(turnSeq int64, requestID string, text string, reasoni
|
||||
}
|
||||
|
||||
func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string, text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) HistoryEntry {
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: newAssistantTextPayload(text, reasoningContent, reasoningSignature, reasoningSignatureSource, reasoningItemID, reasoningStatus, reasoningSummary),
|
||||
}
|
||||
}
|
||||
|
||||
func newAssistantTextPayload(text string, reasoningContent string, reasoningSignature string, reasoningSignatureSource string, reasoningItemID string, reasoningStatus string, reasoningSummary json.RawMessage) json.RawMessage {
|
||||
payload, _ := json.Marshal(assistantTextPayload{
|
||||
Text: text,
|
||||
ReasoningContent: reasoningContent,
|
||||
@@ -2413,13 +2555,7 @@ func newAssistantTextEntryWithProviderMetadata(turnSeq int64, requestID string,
|
||||
ReasoningStatus: strings.TrimSpace(reasoningStatus),
|
||||
ReasoningSummary: append(json.RawMessage(nil), reasoningSummary...),
|
||||
})
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
Role: "assistant",
|
||||
Kind: "assistant_text",
|
||||
Payload: payload,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// newToolCallEntry 构造 tool_call entry。
|
||||
@@ -2643,6 +2779,38 @@ func conversationActionIsResume(action *agentv1.ConversationAction) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func inboundConversationAction(message *agentv1.AgentClientMessage) *agentv1.ConversationAction {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
if action := message.GetConversationAction(); action != nil {
|
||||
return action
|
||||
}
|
||||
if runRequest := message.GetRunRequest(); runRequest != nil {
|
||||
return runRequest.GetAction()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func conversationActionIsSummarize(action *agentv1.ConversationAction) bool {
|
||||
if action == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := action.GetAction().(*agentv1.ConversationAction_SummarizeAction)
|
||||
return ok
|
||||
}
|
||||
|
||||
func resolveInboundManualCompaction(message *agentv1.AgentClientMessage, userMessage *agentv1.UserMessage) manualCompactionDirective {
|
||||
instruction, requested := parseManualCompactionRequest(userMessage)
|
||||
if conversationActionIsSummarize(inboundConversationAction(message)) {
|
||||
requested = true
|
||||
}
|
||||
return manualCompactionDirective{
|
||||
Requested: requested,
|
||||
Instruction: instruction,
|
||||
}
|
||||
}
|
||||
|
||||
func conversationActionStartsRun(action *agentv1.ConversationAction) bool {
|
||||
if action == nil {
|
||||
return false
|
||||
@@ -2650,6 +2818,7 @@ func conversationActionStartsRun(action *agentv1.ConversationAction) bool {
|
||||
switch action.GetAction().(type) {
|
||||
case *agentv1.ConversationAction_UserMessageAction,
|
||||
*agentv1.ConversationAction_ResumeAction,
|
||||
*agentv1.ConversationAction_SummarizeAction,
|
||||
*agentv1.ConversationAction_StartPlanAction,
|
||||
*agentv1.ConversationAction_ExecutePlanAction:
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
execbridge "cursor/internal/backend/agent/bridge/exec"
|
||||
runtimecore "cursor/internal/backend/agent/core"
|
||||
)
|
||||
|
||||
func TestHandleExecResultPublishesShellToolCallDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shellStream func() *agentv1.ShellStream
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "stdout",
|
||||
shellStream: func() *agentv1.ShellStream {
|
||||
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stdout{
|
||||
Stdout: &agentv1.ShellStreamStdout{Data: "stdout chunk\n"},
|
||||
}}
|
||||
},
|
||||
wantStdout: "stdout chunk\n",
|
||||
},
|
||||
{
|
||||
name: "stderr",
|
||||
shellStream: func() *agentv1.ShellStream {
|
||||
return &agentv1.ShellStream{Event: &agentv1.ShellStream_Stderr{
|
||||
Stderr: &agentv1.ShellStreamStderr{Data: "stderr chunk\n"},
|
||||
}}
|
||||
},
|
||||
wantStderr: "stderr chunk\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
broker := NewStreamBroker()
|
||||
service := &Service{
|
||||
broker: broker,
|
||||
execBridge: execbridge.NewBridge(),
|
||||
}
|
||||
stream, err := broker.OpenStream(
|
||||
"request-1", "conversation-1", 1, "default", "default",
|
||||
agentv1.AgentMode_AGENT_MODE_AGENT, "run command",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenStream() error = %v", err)
|
||||
}
|
||||
pending := runtimecore.PendingExec{
|
||||
MessageID: 42,
|
||||
ExecID: "exec-shell-1",
|
||||
ModelCallID: "model-call-1",
|
||||
ToolCallID: "tool-call-1",
|
||||
ExecKind: "shell",
|
||||
}
|
||||
stream.mu.Lock()
|
||||
stream.PendingExecs[pending.ExecID] = pending
|
||||
stream.mu.Unlock()
|
||||
|
||||
if err := service.handleExecResult(InboundIntent{
|
||||
Kind: "exec_result",
|
||||
RequestID: "request-1",
|
||||
ExecClientMessage: &agentv1.ExecClientMessage{
|
||||
Id: pending.MessageID,
|
||||
ExecId: pending.ExecID,
|
||||
Message: &agentv1.ExecClientMessage_ShellStream{
|
||||
ShellStream: test.shellStream(),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("handleExecResult() error = %v", err)
|
||||
}
|
||||
|
||||
events, err := broker.ReadFromCursor("request-1", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFromCursor() error = %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("published events = %d, want compatibility and tool-call deltas", len(events))
|
||||
}
|
||||
|
||||
var compatibilityCount, toolCallDeltaCount int
|
||||
for _, event := range events {
|
||||
update := event.Message.GetInteractionUpdate()
|
||||
if update.GetShellOutputDelta() != nil {
|
||||
compatibilityCount++
|
||||
}
|
||||
deltaUpdate := update.GetToolCallDelta()
|
||||
if deltaUpdate == nil {
|
||||
continue
|
||||
}
|
||||
toolCallDeltaCount++
|
||||
if deltaUpdate.GetCallId() != pending.ToolCallID || deltaUpdate.GetModelCallId() != pending.ModelCallID {
|
||||
t.Fatalf("tool-call delta ids = call %q model %q", deltaUpdate.GetCallId(), deltaUpdate.GetModelCallId())
|
||||
}
|
||||
shellDelta := deltaUpdate.GetToolCallDelta().GetShellToolCallDelta()
|
||||
if shellDelta == nil || shellDelta.GetStdout().GetContent() != test.wantStdout || shellDelta.GetStderr().GetContent() != test.wantStderr {
|
||||
t.Fatalf("shell tool-call delta = %#v", shellDelta)
|
||||
}
|
||||
}
|
||||
if compatibilityCount != 1 || toolCallDeltaCount != 1 {
|
||||
t.Fatalf("published compatibility=%d tool_call_delta=%d, want one each", compatibilityCount, toolCallDeltaCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildShellToolCallDeltaMessageIgnoresNonOutputEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output *agentv1.ShellOutputDeltaUpdate
|
||||
}{
|
||||
{name: "nil"},
|
||||
{
|
||||
name: "start",
|
||||
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Start{
|
||||
Start: &agentv1.ShellStreamStart{},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "exit",
|
||||
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Exit{
|
||||
Exit: &agentv1.ShellStreamExit{},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "empty stdout",
|
||||
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stdout{
|
||||
Stdout: &agentv1.ShellStreamStdout{},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "empty stderr",
|
||||
output: &agentv1.ShellOutputDeltaUpdate{Event: &agentv1.ShellOutputDeltaUpdate_Stderr{
|
||||
Stderr: &agentv1.ShellStreamStderr{},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if message := buildShellToolCallDeltaMessage("tool-call-1", "model-call-1", test.output); message != nil {
|
||||
t.Fatalf("buildShellToolCallDeltaMessage() = %#v, want nil", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,25 @@ func supportedToolNamesForMode(mode agentv1.AgentMode) map[string]struct{} {
|
||||
}
|
||||
}
|
||||
|
||||
func isKnownToolName(toolName string) bool {
|
||||
trimmedToolName := strings.TrimSpace(toolName)
|
||||
if trimmedToolName == "" {
|
||||
return false
|
||||
}
|
||||
for _, supported := range []map[string]struct{}{
|
||||
agentModeToolNames,
|
||||
askModeToolNames,
|
||||
planModeToolNames,
|
||||
debugModeToolNames,
|
||||
multitaskModeToolNames,
|
||||
} {
|
||||
if _, ok := supported[trimmedToolName]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isToolAllowedInMode(mode agentv1.AgentMode, subagentTypeName string, toolName string) bool {
|
||||
trimmedToolName := strings.TrimSpace(toolName)
|
||||
if trimmedToolName == "" {
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
)
|
||||
|
||||
var (
|
||||
transcriptContextTagPatterns = compileTranscriptContextTagPatterns([]string{
|
||||
"user_info",
|
||||
"project_layout",
|
||||
"rules",
|
||||
"always_applied_workspace_rules",
|
||||
"agent_requestable_workspace_rules",
|
||||
"user_rules",
|
||||
"agent_skills",
|
||||
"available_skills",
|
||||
"cloud_instructions",
|
||||
"cloud_task_instructions",
|
||||
"open_and_recently_viewed_files",
|
||||
"system_reminder",
|
||||
"system-reminder",
|
||||
"mcp_instructions",
|
||||
"mcp_file_system",
|
||||
"mcp_file_system_servers",
|
||||
"git_status",
|
||||
"agent_transcripts",
|
||||
"cursor_rules_context",
|
||||
"attached_files",
|
||||
"system_notification",
|
||||
"task_notification",
|
||||
"agent_notification",
|
||||
})
|
||||
transcriptThinkingPattern = regexp.MustCompile(`(?is)<(?:think|thinking)>.*?</(?:think|thinking)>`)
|
||||
transcriptBlankLinesPattern = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
type cursorTranscriptLine struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Message *cursorTranscriptMessage `json:"message,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cursorTranscriptMessage struct {
|
||||
Content []cursorTranscriptContent `json:"content"`
|
||||
}
|
||||
|
||||
type cursorTranscriptContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input any `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// projectCursorTranscriptJSONL projects the local semantic history into Cursor's
|
||||
// current agent transcript JSONL contract. context.json remains the source of truth.
|
||||
func projectCursorTranscriptJSONL(conversation *ConversationFile) ([]byte, error) {
|
||||
return projectCursorTranscriptJSONLWithLatestStatus(conversation, false)
|
||||
}
|
||||
|
||||
func projectCursorTranscriptJSONLWithLatestStatus(conversation *ConversationFile, includeLatestStatus bool) ([]byte, error) {
|
||||
if conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
lines := make([]cursorTranscriptLine, 0, len(conversation.Entries))
|
||||
maxTurnSeq := int64(0)
|
||||
for _, entry := range conversation.Entries {
|
||||
if entry.TurnSeq > maxTurnSeq {
|
||||
maxTurnSeq = entry.TurnSeq
|
||||
}
|
||||
}
|
||||
currentTurnSeq := int64(0)
|
||||
pendingTurnStatus := cursorTranscriptLine{}
|
||||
flushTurnStatus := func() {
|
||||
if currentTurnSeq > 0 && (includeLatestStatus || currentTurnSeq < maxTurnSeq) && pendingTurnStatus.Type != "" {
|
||||
lines = append(lines, pendingTurnStatus)
|
||||
}
|
||||
pendingTurnStatus = cursorTranscriptLine{}
|
||||
}
|
||||
for _, entry := range conversation.Entries {
|
||||
if entry.TurnSeq > 0 && entry.TurnSeq != currentTurnSeq {
|
||||
flushTurnStatus()
|
||||
currentTurnSeq = entry.TurnSeq
|
||||
}
|
||||
projected, ok, err := projectCursorTranscriptEntry(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
lines = append(lines, projected)
|
||||
}
|
||||
if status, ok := cursorTranscriptTurnStatus(entry); ok {
|
||||
pendingTurnStatus = status
|
||||
}
|
||||
}
|
||||
flushTurnStatus()
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var output bytes.Buffer
|
||||
encoder := json.NewEncoder(&output)
|
||||
encoder.SetEscapeHTML(false)
|
||||
for _, line := range lines {
|
||||
if err := encoder.Encode(line); err != nil {
|
||||
return nil, fmt.Errorf("encode cursor transcript line: %w", err)
|
||||
}
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func projectCursorTranscriptEntry(entry HistoryEntry) (cursorTranscriptLine, bool, error) {
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "user_message":
|
||||
message := &agentv1.UserMessage{}
|
||||
if err := protojson.Unmarshal(entry.Payload, message); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript user_message: %w", err)
|
||||
}
|
||||
text := cleanCursorTranscriptUserText(message.GetText())
|
||||
if text == "" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptTextLine("user", text), true, nil
|
||||
case "assistant_text":
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript assistant_text: %w", err)
|
||||
}
|
||||
text := cleanCursorTranscriptAssistantText(payload.Text)
|
||||
thinking := strings.TrimSpace(payload.ReasoningContent)
|
||||
content := joinTranscriptText(text, thinking)
|
||||
if content == "" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptTextLine("assistant", content), true, nil
|
||||
case "tool_call":
|
||||
var payload toolCallEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call: %w", err)
|
||||
}
|
||||
toolCall := &agentv1.ToolCall{}
|
||||
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript tool_call payload: %w", err)
|
||||
}
|
||||
descriptor, ok := promptengine.BuildToolCallReplayDescriptor(firstNonEmpty(payload.ToolCallID, entry.ToolCallID), toolCall)
|
||||
if !ok {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptToolCallLine(descriptor.Function.Name, descriptor.Function.Arguments, payload.ReasoningContent), true, nil
|
||||
case "model_message":
|
||||
var payload modelMessageEntryPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false, fmt.Errorf("decode transcript model_message: %w", err)
|
||||
}
|
||||
return projectCursorTranscriptModelMessage(payload.Message)
|
||||
default:
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func projectCursorTranscriptModelMessage(message modeladapter.Message) (cursorTranscriptLine, bool, error) {
|
||||
role := strings.TrimSpace(message.Role)
|
||||
if role == "" || role == "system" || role == "tool" {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
content := make([]cursorTranscriptContent, 0, len(message.ToolCalls)+1)
|
||||
texts := make([]string, 0, len(message.ContentParts)+2)
|
||||
if text := strings.TrimSpace(message.Content); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
for _, part := range message.ContentParts {
|
||||
switch strings.TrimSpace(strings.ToLower(part.Type)) {
|
||||
case "text", "":
|
||||
if text := strings.TrimSpace(part.Text); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case "image":
|
||||
texts = append(texts, "[Image]")
|
||||
}
|
||||
}
|
||||
if thinking := strings.TrimSpace(message.ReasoningContent); thinking != "" {
|
||||
texts = append(texts, thinking)
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
text := strings.Join(texts, "\n\n")
|
||||
if role == "user" {
|
||||
text = cleanCursorTranscriptUserText(text)
|
||||
} else if role == "assistant" {
|
||||
text = cleanCursorTranscriptAssistantText(text)
|
||||
}
|
||||
if text != "" {
|
||||
content = append(content, cursorTranscriptContent{Type: "text", Text: text})
|
||||
}
|
||||
}
|
||||
for _, call := range message.ToolCalls {
|
||||
name := strings.TrimSpace(call.Function.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, cursorTranscriptContent{
|
||||
Type: "tool_use",
|
||||
Name: name,
|
||||
Input: decodeTranscriptToolInput(call.Function.Arguments),
|
||||
})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return cursorTranscriptLine{}, false, nil
|
||||
}
|
||||
return cursorTranscriptLine{Role: role, Message: &cursorTranscriptMessage{Content: content}}, true, nil
|
||||
}
|
||||
|
||||
func cursorTranscriptTextLine(role string, text string) cursorTranscriptLine {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return cursorTranscriptLine{}
|
||||
}
|
||||
return cursorTranscriptLine{
|
||||
Role: strings.TrimSpace(role),
|
||||
Message: &cursorTranscriptMessage{Content: []cursorTranscriptContent{{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
func cursorTranscriptToolCallLine(name string, arguments string, reasoning string) cursorTranscriptLine {
|
||||
content := make([]cursorTranscriptContent, 0, 2)
|
||||
if thinking := strings.TrimSpace(reasoning); thinking != "" {
|
||||
content = append(content, cursorTranscriptContent{Type: "text", Text: thinking})
|
||||
}
|
||||
content = append(content, cursorTranscriptContent{
|
||||
Type: "tool_use",
|
||||
Name: strings.TrimSpace(name),
|
||||
Input: decodeTranscriptToolInput(arguments),
|
||||
})
|
||||
return cursorTranscriptLine{Role: "assistant", Message: &cursorTranscriptMessage{Content: content}}
|
||||
}
|
||||
|
||||
func decodeTranscriptToolInput(arguments string) any {
|
||||
trimmed := strings.TrimSpace(arguments)
|
||||
if trimmed == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func cursorTranscriptTurnStatus(entry HistoryEntry) (cursorTranscriptLine, bool) {
|
||||
if strings.TrimSpace(entry.Kind) != "metadata" || entry.TurnSeq <= 0 {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
var payload metadataPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
switch strings.TrimSpace(payload.Type) {
|
||||
case "turn_completed":
|
||||
return cursorTranscriptLine{Type: "turn_ended", Status: "success"}, true
|
||||
case "provider_error", "failed":
|
||||
return cursorTranscriptLine{
|
||||
Type: "turn_ended",
|
||||
Status: "error",
|
||||
Error: firstNonEmpty(readStringValue(payload.Value["error"]), readStringValue(payload.Value["message"]), "Request failed"),
|
||||
}, true
|
||||
case "control":
|
||||
if strings.TrimSpace(readStringValue(payload.Value["status"])) != "canceled" {
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
return cursorTranscriptLine{
|
||||
Type: "turn_ended",
|
||||
Status: "aborted",
|
||||
Error: firstNonEmpty(readStringValue(payload.Value["reason"]), readStringValue(payload.Value["message"]), "User aborted request"),
|
||||
}, true
|
||||
default:
|
||||
return cursorTranscriptLine{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func cleanCursorTranscriptUserText(text string) string {
|
||||
return cleanTranscriptContextTags(text)
|
||||
}
|
||||
|
||||
func cleanCursorTranscriptAssistantText(text string) string {
|
||||
cleaned := transcriptThinkingPattern.ReplaceAllString(text, "")
|
||||
return collapseTranscriptBlankLines(cleaned)
|
||||
}
|
||||
|
||||
func cleanTranscriptContextTags(text string) string {
|
||||
cleaned := text
|
||||
for _, pattern := range transcriptContextTagPatterns {
|
||||
cleaned = pattern.ReplaceAllString(cleaned, "")
|
||||
}
|
||||
return collapseTranscriptBlankLines(cleaned)
|
||||
}
|
||||
|
||||
func compileTranscriptContextTagPatterns(tags []string) []*regexp.Regexp {
|
||||
patterns := make([]*regexp.Regexp, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
patterns = append(patterns, regexp.MustCompile(`(?is)<`+regexp.QuoteMeta(tag)+`(?:\s[^>]*)?>.*?</`+regexp.QuoteMeta(tag)+`>`))
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
func collapseTranscriptBlankLines(text string) string {
|
||||
return strings.TrimSpace(transcriptBlankLinesPattern.ReplaceAllString(text, "\n\n"))
|
||||
}
|
||||
|
||||
func joinTranscriptText(text string, thinking string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if strings.TrimSpace(text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(text))
|
||||
}
|
||||
if strings.TrimSpace(thinking) != "" {
|
||||
parts = append(parts, strings.TrimSpace(thinking))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func normalizeAgentTranscriptsFolder(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" || !filepath.IsAbs(trimmed) {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(trimmed)
|
||||
if filepath.Base(cleaned) != "agent-transcripts" {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func agentTranscriptsFolderFromEntries(entries []HistoryEntry) string {
|
||||
for _, entry := range entries {
|
||||
if strings.TrimSpace(entry.Kind) != "request_context" {
|
||||
continue
|
||||
}
|
||||
requestContext := &agentv1.RequestContext{}
|
||||
if err := protojson.Unmarshal(entry.Payload, requestContext); err != nil {
|
||||
continue
|
||||
}
|
||||
if folder := normalizeAgentTranscriptsFolder(requestContext.GetEnv().GetAgentTranscriptsFolder()); folder != "" {
|
||||
return folder
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cursorTranscriptPath(transcriptsFolder string, conversationID string) (string, error) {
|
||||
folder := normalizeAgentTranscriptsFolder(transcriptsFolder)
|
||||
if folder == "" {
|
||||
return "", fmt.Errorf("invalid agent transcripts folder")
|
||||
}
|
||||
id, err := validateConversationID(conversationID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(folder, id, id+".jsonl"), nil
|
||||
}
|
||||
|
||||
func preserveCursorAppendedTurnEnded(path string, projected []byte) []byte {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return projected
|
||||
}
|
||||
lastLine := lastNonEmptyJSONLLine(existing)
|
||||
if len(lastLine) == 0 {
|
||||
return projected
|
||||
}
|
||||
var terminal cursorTranscriptLine
|
||||
if json.Unmarshal(lastLine, &terminal) != nil || terminal.Type != "turn_ended" {
|
||||
return projected
|
||||
}
|
||||
if countTranscriptTurnEnded(existing) <= countTranscriptTurnEnded(projected) {
|
||||
return projected
|
||||
}
|
||||
result := append([]byte(nil), projected...)
|
||||
if len(result) > 0 && result[len(result)-1] != '\n' {
|
||||
result = append(result, '\n')
|
||||
}
|
||||
result = append(result, lastLine...)
|
||||
return append(result, '\n')
|
||||
}
|
||||
|
||||
func lastNonEmptyJSONLLine(data []byte) []byte {
|
||||
lines := bytes.Split(data, []byte{'\n'})
|
||||
for index := len(lines) - 1; index >= 0; index-- {
|
||||
if line := bytes.TrimSpace(lines[index]); len(line) > 0 {
|
||||
return append([]byte(nil), line...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func countTranscriptTurnEnded(data []byte) int {
|
||||
count := 0
|
||||
for _, line := range bytes.Split(data, []byte{'\n'}) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if len(trimmed) == 0 {
|
||||
continue
|
||||
}
|
||||
var item cursorTranscriptLine
|
||||
if json.Unmarshal(trimmed, &item) == nil && item.Type == "turn_ended" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func writeCursorTranscriptAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create transcript directory: %w", err)
|
||||
}
|
||||
file, tempPath, err := openUniqueArtifactTempFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open transcript temp file: %w", err)
|
||||
}
|
||||
renamed := false
|
||||
defer func() {
|
||||
if !renamed {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("write transcript temp file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close transcript temp file: %w", err)
|
||||
}
|
||||
if err := renameArtifactTempFile(tempPath, path); err != nil {
|
||||
return fmt.Errorf("rename transcript temp file: %w", err)
|
||||
}
|
||||
renamed = true
|
||||
return syncDirectory(filepath.Dir(path))
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestProjectCursorTranscriptJSONLMatchesCursorContract(t *testing.T) {
|
||||
toolCall := transcriptTestEditToolCall(t, "file.txt")
|
||||
conversation := transcriptTestConversation([]HistoryEntry{
|
||||
transcriptTestUserMessageEntry(t, 1, "request-1", "<user_info>hidden</user_info>\n\nchange the file"),
|
||||
newAssistantTextEntry(1, "request-1", "<thinking>hidden</thinking>\nDone", "checked carefully", ""),
|
||||
newToolCallEntry(1, "request-1", "call-1", "Edit", "", "", toolCall),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Edit", `{"path":"file.txt"}`, "edited", "", toolCall),
|
||||
newMetadataEntry(1, "request-1", "turn_completed", nil),
|
||||
transcriptTestUserMessageEntry(t, 2, "request-2", "next question"),
|
||||
newMetadataEntry(2, "request-2", "turn_completed", nil),
|
||||
})
|
||||
|
||||
data, err := projectCursorTranscriptJSONL(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("projectCursorTranscriptJSONL() error = %v", err)
|
||||
}
|
||||
lines := decodeCursorTranscriptLines(t, data)
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("transcript lines = %d, want 5\n%s", len(lines), data)
|
||||
}
|
||||
|
||||
if lines[0].Role != "user" || transcriptLineText(lines[0]) != "change the file" {
|
||||
t.Fatalf("user line = %#v", lines[0])
|
||||
}
|
||||
if lines[1].Role != "assistant" || transcriptLineText(lines[1]) != "Done\n\nchecked carefully" {
|
||||
t.Fatalf("assistant line = %#v", lines[1])
|
||||
}
|
||||
if lines[2].Role != "assistant" || lines[2].Message == nil || len(lines[2].Message.Content) != 1 {
|
||||
t.Fatalf("tool line = %#v", lines[2])
|
||||
}
|
||||
toolUse := lines[2].Message.Content[0]
|
||||
if toolUse.Type != "tool_use" || toolUse.Name != "Edit" {
|
||||
t.Fatalf("tool use = %#v", toolUse)
|
||||
}
|
||||
input, ok := toolUse.Input.(map[string]any)
|
||||
if !ok || input["path"] != "file.txt" {
|
||||
t.Fatalf("tool input = %#v", toolUse.Input)
|
||||
}
|
||||
if lines[3].Type != "turn_ended" || lines[3].Status != "success" {
|
||||
t.Fatalf("turn status = %#v", lines[3])
|
||||
}
|
||||
if lines[4].Role != "user" || transcriptLineText(lines[4]) != "next question" {
|
||||
t.Fatalf("current user line = %#v", lines[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationFileStoreSyncsCursorTranscript(t *testing.T) {
|
||||
historyRoot := filepath.Join(t.TempDir(), "history")
|
||||
transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts")
|
||||
store := NewConversationFileStore(historyRoot)
|
||||
conversation := transcriptTestConversation(nil)
|
||||
conversation.AgentTranscriptsFolder = transcriptsFolder
|
||||
|
||||
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||
transcriptTestUserMessageEntry(t, 1, "request-1", "hello"),
|
||||
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
if persisted.AgentTranscriptsFolder != transcriptsFolder {
|
||||
t.Fatalf("persisted transcript folder = %q", persisted.AgentTranscriptsFolder)
|
||||
}
|
||||
|
||||
path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read synced transcript: %v", err)
|
||||
}
|
||||
lines := decodeCursorTranscriptLines(t, data)
|
||||
if len(lines) != 2 || lines[0].Role != "user" || lines[1].Role != "assistant" {
|
||||
t.Fatalf("synced transcript = %s", data)
|
||||
}
|
||||
|
||||
reloaded, err := store.LoadConversation(conversation.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
if reloaded.AgentTranscriptsFolder != transcriptsFolder {
|
||||
t.Fatalf("reloaded transcript folder = %q", reloaded.AgentTranscriptsFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationFileStoreBackfillsTranscriptOnStartup(t *testing.T) {
|
||||
historyRoot := filepath.Join(t.TempDir(), "history")
|
||||
transcriptsFolder := filepath.Join(t.TempDir(), "agent-transcripts")
|
||||
if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil {
|
||||
t.Fatalf("create transcript root: %v", err)
|
||||
}
|
||||
store := NewConversationFileStore(historyRoot)
|
||||
conversation := transcriptTestConversation(nil)
|
||||
conversation.AgentTranscriptsFolder = transcriptsFolder
|
||||
_, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||
transcriptTestUserMessageEntry(t, 1, "request-1", "hello"),
|
||||
newAssistantTextEntry(1, "request-1", "hi", "", ""),
|
||||
newMetadataEntry(1, "request-1", "turn_completed", nil),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
path := filepath.Join(transcriptsFolder, conversation.ConversationID, conversation.ConversationID+".jsonl")
|
||||
if err := os.RemoveAll(filepath.Dir(path)); err != nil {
|
||||
t.Fatalf("remove generated transcript: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(transcriptsFolder, 0o755); err != nil {
|
||||
t.Fatalf("restore transcript root: %v", err)
|
||||
}
|
||||
|
||||
store.SyncAllCursorTranscriptsBestEffort()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read backfilled transcript: %v", err)
|
||||
}
|
||||
lines := decodeCursorTranscriptLines(t, data)
|
||||
if len(lines) != 3 || lines[2].Type != "turn_ended" || lines[2].Status != "success" {
|
||||
t.Fatalf("backfilled transcript = %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentTranscriptsFolderRejectsUnexpectedPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "agent-transcripts")); got == "" {
|
||||
t.Fatal("valid transcript folder was rejected")
|
||||
}
|
||||
if got := normalizeAgentTranscriptsFolder(filepath.Join(root, "other")); got != "" {
|
||||
t.Fatalf("unexpected folder accepted: %q", got)
|
||||
}
|
||||
if got := normalizeAgentTranscriptsFolder("agent-transcripts"); got != "" {
|
||||
t.Fatalf("relative folder accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveCursorAppendedTurnEnded(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "conversation.jsonl")
|
||||
existing := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"type\":\"turn_ended\",\"status\":\"success\"}\n")
|
||||
if err := os.WriteFile(path, existing, 0o644); err != nil {
|
||||
t.Fatalf("write existing transcript: %v", err)
|
||||
}
|
||||
projected := []byte("{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}\n")
|
||||
preserved := preserveCursorAppendedTurnEnded(path, projected)
|
||||
if countTranscriptTurnEnded(preserved) != 1 {
|
||||
t.Fatalf("preserved transcript = %s", preserved)
|
||||
}
|
||||
if !strings.HasSuffix(string(preserved), "{\"type\":\"turn_ended\",\"status\":\"success\"}\n") {
|
||||
t.Fatalf("terminal line not preserved: %s", preserved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTranscriptsFolderRecoveredFromLegacyRequestContext(t *testing.T) {
|
||||
folder := filepath.Join(t.TempDir(), "agent-transcripts")
|
||||
payload, err := protojson.Marshal(&agentv1.RequestContext{
|
||||
Env: &agentv1.RequestContextEnv{AgentTranscriptsFolder: folder},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request context: %v", err)
|
||||
}
|
||||
conversation := transcriptTestConversation([]HistoryEntry{{
|
||||
TurnSeq: 1,
|
||||
Role: "user",
|
||||
Kind: "request_context",
|
||||
Payload: payload,
|
||||
}})
|
||||
conversation.AgentTranscriptsFolder = ""
|
||||
normalizeLoadedConversation(conversation.ConversationID, conversation)
|
||||
if conversation.AgentTranscriptsFolder != folder {
|
||||
t.Fatalf("recovered transcript folder = %q", conversation.AgentTranscriptsFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptTestConversation(entries []HistoryEntry) *ConversationFile {
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 1,
|
||||
NextEntrySeq: 1,
|
||||
Entries: make([]HistoryEntry, 0, len(entries)),
|
||||
}
|
||||
appendEntriesInPlace(conversation, entries)
|
||||
return conversation
|
||||
}
|
||||
|
||||
func transcriptTestUserMessageEntry(t *testing.T, turnSeq int64, requestID string, text string) HistoryEntry {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: fmt.Sprintf("message-%d", turnSeq)})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: requestID,
|
||||
Role: "user",
|
||||
Kind: "user_message",
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptTestEditToolCall(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(&agentv1.ToolCall{
|
||||
Tool: &agentv1.ToolCall_EditToolCall{
|
||||
EditToolCall: &agentv1.EditToolCall{
|
||||
Args: &agentv1.EditArgs{Path: path},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal edit tool call: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodeCursorTranscriptLines(t *testing.T, data []byte) []cursorTranscriptLine {
|
||||
t.Helper()
|
||||
lines := make([]cursorTranscriptLine, 0)
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for scanner.Scan() {
|
||||
var line cursorTranscriptLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||
t.Fatalf("decode transcript line %q: %v", scanner.Text(), err)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan transcript: %v", err)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func transcriptLineText(line cursorTranscriptLine) string {
|
||||
if line.Message == nil {
|
||||
return ""
|
||||
}
|
||||
texts := make([]string, 0, len(line.Message.Content))
|
||||
for _, content := range line.Message.Content {
|
||||
if content.Type == "text" {
|
||||
texts = append(texts, content.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n\n")
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type ConversationFile struct {
|
||||
ParentConversationID string `json:"parent_conversation_id"`
|
||||
ParentToolCallID string `json:"parent_tool_call_id"`
|
||||
SubagentTypeName string `json:"subagent_type_name,omitempty"`
|
||||
AgentTranscriptsFolder string `json:"agent_transcripts_folder,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
ContextVersion int64 `json:"context_version,omitempty"`
|
||||
CurrentLoopID string `json:"current_loop_id,omitempty"`
|
||||
@@ -78,6 +79,7 @@ type HistoryEntry struct {
|
||||
Seq int64 `json:"seq"`
|
||||
TurnSeq int64 `json:"turn_seq"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Kind string `json:"kind"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
@@ -116,6 +118,11 @@ type StreamSubscriber struct {
|
||||
Signal chan struct{}
|
||||
}
|
||||
|
||||
type manualCompactionDirective struct {
|
||||
Requested bool
|
||||
Instruction string
|
||||
}
|
||||
|
||||
type ActiveStream struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -126,6 +133,7 @@ type ActiveStream struct {
|
||||
ModelName string
|
||||
Mode agentv1.AgentMode
|
||||
LatestUserText string
|
||||
ManualCompaction manualCompactionDirective
|
||||
Status StreamStatus
|
||||
ThinkingEffort string
|
||||
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
|
||||
@@ -156,6 +164,10 @@ type ActiveStream struct {
|
||||
ProviderUsage turnUsageSnapshot
|
||||
ProviderTerminalToolInvocation bool
|
||||
PendingCompaction *PendingCompaction
|
||||
PendingCheckpointBlobWrites map[uint32]string
|
||||
ConfirmedCheckpointBlobs map[string]struct{}
|
||||
NextCheckpointBlobRequestID uint32
|
||||
PendingCheckpoint *pendingCheckpointPublish
|
||||
|
||||
Backlog []StreamEvent
|
||||
Subscribers map[string]*StreamSubscriber
|
||||
@@ -212,6 +224,13 @@ type pendingTurnCompletion struct {
|
||||
Disposition pendingCompletionDisposition
|
||||
}
|
||||
|
||||
type pendingCheckpointPublish struct {
|
||||
State *agentv1.ConversationStateStructure
|
||||
Required map[string]struct{}
|
||||
Completion *pendingTurnCompletion
|
||||
Published bool
|
||||
}
|
||||
|
||||
type PendingCompaction struct {
|
||||
Trigger string
|
||||
ContextTokens int64
|
||||
@@ -407,6 +426,7 @@ type InboundIntent struct {
|
||||
HasExplicitMode bool
|
||||
ModeSource ModeSource
|
||||
StartsRun bool
|
||||
ManualCompaction manualCompactionDirective
|
||||
SubagentTypeName string
|
||||
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
|
||||
ConversationState *agentv1.ConversationStateStructure
|
||||
|
||||
+217
-281
@@ -2,6 +2,8 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -31,6 +33,7 @@ type Host struct {
|
||||
listenAddr string
|
||||
configs *serverconfig.Manager
|
||||
healthHTTP *http.Client
|
||||
tlsCertificate *tls.Certificate
|
||||
|
||||
runMu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
@@ -40,7 +43,20 @@ type Host struct {
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func NewHost(store *serverconfig.Store) (*Host, error) {
|
||||
type HostOption func(*Host) error
|
||||
|
||||
func WithTLSCertificate(certificate *tls.Certificate) HostOption {
|
||||
return func(host *Host) error {
|
||||
if certificate == nil || len(certificate.Certificate) == 0 || certificate.PrivateKey == nil {
|
||||
return fmt.Errorf("backend TLS certificate is invalid")
|
||||
}
|
||||
copied := *certificate
|
||||
host.tlsCertificate = &copied
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewHost(store *serverconfig.Store, options ...HostOption) (*Host, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("backend config store is required")
|
||||
}
|
||||
@@ -53,8 +69,16 @@ func NewHost(store *serverconfig.Store) (*Host, error) {
|
||||
store: store,
|
||||
listenAddr: cfg.BackendListenAddr,
|
||||
configs: configs,
|
||||
healthHTTP: newLoopbackHTTPClient(),
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
if err := option(host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
host.healthHTTP = newLoopbackHTTPClient(host.tlsCertificate)
|
||||
if err := host.rebuild(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,8 +129,15 @@ func (host *Host) BaseURL() string {
|
||||
if listenAddr == "" {
|
||||
return ""
|
||||
}
|
||||
if host.tlsCertificate == nil {
|
||||
return "http://" + listenAddr
|
||||
}
|
||||
serverName := "localhost"
|
||||
if _, port, err := net.SplitHostPort(listenAddr); err == nil {
|
||||
return "https://" + net.JoinHostPort(serverName, port)
|
||||
}
|
||||
return "https://" + listenAddr
|
||||
}
|
||||
|
||||
func (host *Host) IsRunning() bool {
|
||||
if host == nil {
|
||||
@@ -151,6 +182,12 @@ func (host *Host) Start() error {
|
||||
host.lastRunErr = fmt.Errorf("监听内置后端 %s 失败: %w", host.listenAddr, err)
|
||||
return host.lastRunErr
|
||||
}
|
||||
if host.tlsCertificate != nil {
|
||||
listener = tls.NewListener(listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{*host.tlsCertificate},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}
|
||||
host.listenAddr = listener.Addr().String()
|
||||
host.httpServer = httpServer
|
||||
host.lastRunErr = nil
|
||||
@@ -200,7 +237,7 @@ func (host *Host) HealthCheck(ctx context.Context) error {
|
||||
}
|
||||
client := host.healthHTTP
|
||||
if client == nil {
|
||||
client = newLoopbackHTTPClient()
|
||||
client = newLoopbackHTTPClient(host.tlsCertificate)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -243,9 +280,8 @@ func (host *Host) InProcessHealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLoopbackHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
func newLoopbackHTTPClient(certificate *tls.Certificate) *http.Client {
|
||||
transport := &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 1 * time.Second,
|
||||
@@ -255,7 +291,23 @@ func newLoopbackHTTPClient() *http.Client {
|
||||
MaxIdleConns: 1,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
if certificate != nil {
|
||||
roots := x509.NewCertPool()
|
||||
for _, rawCertificate := range certificate.Certificate[1:] {
|
||||
parsed, err := x509.ParseCertificate(rawCertificate)
|
||||
if err == nil {
|
||||
roots.AddCert(parsed)
|
||||
}
|
||||
}
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: roots,
|
||||
ServerName: "localhost",
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,12 +326,23 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
SystemSettingService: &serverSystemSettings{configs: host.configs},
|
||||
HTTPClient: netproxy.NewHTTPClient(30000 * time.Second),
|
||||
}
|
||||
fallbackForward := upstream.FallbackForwardAction(
|
||||
routeDeps,
|
||||
upstream.CompatRouteConfig{Name: "upstream_fallback"},
|
||||
upstream.DefaultCursorUpstreamBaseURL,
|
||||
)
|
||||
localAIAction := server.HTTPHandlerAction(agentModule.AiHandler)
|
||||
aiServiceAction := func(ctx *server.Context) error {
|
||||
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil && agentModule.HandlesAIPath(ctx.Request.URL.Path) {
|
||||
return localAIAction(ctx)
|
||||
}
|
||||
return fallbackForward(ctx)
|
||||
}
|
||||
|
||||
host.mux = server.New(
|
||||
host.mux = withLocalBackendCORS(server.New(
|
||||
server.Use(
|
||||
server.Recover(),
|
||||
server.ServerContext(),
|
||||
server.PolicyMiddleware(host.configs),
|
||||
server.ErrorEncoder(),
|
||||
),
|
||||
server.Mount(ads.RoutePrefix, ads.NewHTTPHandler(appdata.AdsRootPath())),
|
||||
@@ -292,17 +355,11 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
server.Name("bidi_append"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(server.HTTPHandlerAction(agentModule.LocalBidiHandler)),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "bidi_append",
|
||||
})),
|
||||
),
|
||||
server.POST(legacyRunSSEProcedure,
|
||||
server.Name("run_sse"),
|
||||
server.ConnectStream(),
|
||||
server.Local(server.HTTPHandlerAction(agentModule.LocalRunSSE)),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "run_sse",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/ServerTime",
|
||||
server.Name("server_time"),
|
||||
@@ -313,9 +370,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.ServerTimeResponse",
|
||||
MockBuilder: upstream.ServerTimeMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "server_time",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetServerConfig",
|
||||
server.Name("server_config"),
|
||||
@@ -326,8 +380,15 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetServerConfigResponse",
|
||||
MockBuilder: upstream.ServerConfigMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "server_config",
|
||||
),
|
||||
server.POST("/aiserver.v1.ServerConfigService/GetServerConfig",
|
||||
server.Name("server_config_service_get_server_config"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "server_config_service_get_server_config",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetServerConfigResponse",
|
||||
MockBuilder: upstream.ServerConfigMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/AvailableModels",
|
||||
@@ -339,8 +400,35 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.AvailableModelsResponse",
|
||||
MockBuilder: upstream.AvailableModelsMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "available_models",
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetUsableModels",
|
||||
server.Name("usable_models"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "usable_models",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetUsableModelsResponse",
|
||||
MockBuilder: upstream.UsableModelsMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModelForCli",
|
||||
server.Name("default_model_for_cli"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "default_model_for_cli",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetDefaultModelForCliResponse",
|
||||
MockBuilder: upstream.DefaultModelForCliMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModel",
|
||||
server.Name("default_model"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "default_model",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetDefaultModelResponse",
|
||||
MockBuilder: upstream.DefaultModelMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AiService/GetDefaultModelNudgeData",
|
||||
@@ -352,9 +440,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetDefaultModelNudgeDataResponse",
|
||||
MockBuilder: upstream.DefaultModelNudgeMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "default_model_nudge",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AnalyticsService/BootstrapStatsig",
|
||||
server.Name("bootstrap_statsig"),
|
||||
@@ -365,9 +450,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.BootstrapStatsigResponse",
|
||||
MockBuilder: upstream.BootstrapStatsigMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "bootstrap_statsig",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AnalyticsService/GetFirstWindowStatsigDecision",
|
||||
server.Name("first_window_statsig_decision"),
|
||||
@@ -378,113 +460,103 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetFirstWindowStatsigDecisionResponse",
|
||||
MockBuilder: upstream.FirstWindowStatsigDecisionMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "first_window_statsig_decision",
|
||||
})),
|
||||
),
|
||||
server.POST("/oauth/token",
|
||||
server.Name("oauth_token"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockOAuthAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "oauth_token",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "oauth_token",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.AuthService/GetEmail",
|
||||
server.Name("auth_service_get_email"),
|
||||
server.POST("/aiserver.v1.AnalyticsService/SubmitLogs",
|
||||
server.Name("analytics_submit_logs"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockAuthEmailAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_service_get_email",
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "analytics_submit_logs",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_service_get_email",
|
||||
MockProtoType: "aiserver.v1.SubmitLogsResponse",
|
||||
MockBuilder: upstream.SubmitLogsMockBuilder,
|
||||
})),
|
||||
),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/StreamCpp", "ai_stream_cpp", server.ConnectStream(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/StreamNextCursorPrediction", "ai_stream_next_cursor_prediction", server.ConnectStream(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/GetCppEditClassification", "ai_get_cpp_edit_classification", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/RefreshTabContext", "ai_refresh_tab_context", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/CppConfig", "ai_cpp_config", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/CppEditHistoryStatus", "ai_cpp_edit_history_status", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/CppAppend", "ai_cpp_append", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/CppEditHistoryAppend", "ai_cpp_edit_history_append", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/ReportAiCodeChangeMetrics", "ai_report_ai_code_change_metrics", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/WriteGitCommitMessage", "ai_write_git_commit_message", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.AiService/WriteGitBranchName", "ai_write_git_branch_name", server.ConnectUnary(), routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoInitHandshakeV2Procedure, "repository_fast_repo_init_handshake_v2", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoInitHandshakeProcedure, "repository_fast_repo_init_handshake", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoSyncCompleteProcedure, "repository_fast_repo_sync_complete", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceSyncMerkleSubtreeV2Procedure, "repository_sync_merkle_subtree_v2", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceSyncMerkleSubtreeProcedure, "repository_sync_merkle_subtree", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastUpdateFileV2Procedure, "repository_fast_update_file_v2", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastUpdateFileProcedure, "repository_fast_update_file", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceEnsureIndexCreatedProcedure, "repository_ensure_index_created", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetCopyStatusProcedure, "repository_get_copy_status", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetUploadLimitsProcedure, "repository_get_upload_limits", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetNumFilesToSendProcedure, "repository_get_num_files_to_send", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetAvailableChunkingStrategiesProcedure, "repository_get_available_chunking_strategies", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetHighLevelFolderDescriptionProcedure, "repository_get_high_level_folder_description", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceRepositoryStatusProcedure, "repository_status", server.ConnectUnary(), agentModule, routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceBatchRepositoryStatusProcedure, "repository_batch_status", server.ConnectUnary(), agentModule, routeDeps),
|
||||
uploadServiceProcedure(forwarder.UploadServiceUploadDocumentationProcedure, "upload_documentation", server.ConnectUnary(), agentModule, routeDeps),
|
||||
uploadServiceProcedure(forwarder.UploadServiceGetDocProcedure, "upload_get_doc", server.ConnectUnary(), agentModule, routeDeps),
|
||||
uploadServiceProcedure(forwarder.UploadServiceGetPagesProcedure, "upload_get_pages", server.ConnectUnary(), agentModule, routeDeps),
|
||||
uploadServiceProcedure(forwarder.UploadServiceUploadedStatusProcedure, "upload_uploaded_status", server.ConnectUnary(), agentModule, routeDeps),
|
||||
server.POST("/aiserver.v1.AnalyticsService/TrackEvents",
|
||||
server.Name("analytics_track_events"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "analytics_track_events",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.TrackEventsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/v1/traces",
|
||||
server.Name("otlp_traces"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "otlp_traces",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
),
|
||||
server.GET("/auth/cursor_dev_session_token",
|
||||
server.Name("auth_cursor_dev_session_token"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockDevSessionTokenAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_cursor_dev_session_token",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
),
|
||||
tabServerProcedure("/aiserver.v1.AiService/StreamCpp", "ai_stream_cpp", server.ConnectStream(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/StreamNextCursorPrediction", "ai_stream_next_cursor_prediction", server.ConnectStream(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/GetCppEditClassification", "ai_get_cpp_edit_classification", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/RefreshTabContext", "ai_refresh_tab_context", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/CppConfig", "ai_cpp_config", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/CppEditHistoryStatus", "ai_cpp_edit_history_status", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/CppAppend", "ai_cpp_append", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/CppEditHistoryAppend", "ai_cpp_edit_history_append", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/ReportAiCodeChangeMetrics", "ai_report_ai_code_change_metrics", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/WriteGitCommitMessage", "ai_write_git_commit_message", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.AiService/WriteGitBranchName", "ai_write_git_branch_name", server.ConnectUnary(), routeDeps),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoInitHandshakeV2Procedure, "repository_fast_repo_init_handshake_v2", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoInitHandshakeProcedure, "repository_fast_repo_init_handshake", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastRepoSyncCompleteProcedure, "repository_fast_repo_sync_complete", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceSyncMerkleSubtreeV2Procedure, "repository_sync_merkle_subtree_v2", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceSyncMerkleSubtreeProcedure, "repository_sync_merkle_subtree", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastUpdateFileV2Procedure, "repository_fast_update_file_v2", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceFastUpdateFileProcedure, "repository_fast_update_file", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceEnsureIndexCreatedProcedure, "repository_ensure_index_created", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetCopyStatusProcedure, "repository_get_copy_status", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetUploadLimitsProcedure, "repository_get_upload_limits", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetNumFilesToSendProcedure, "repository_get_num_files_to_send", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetAvailableChunkingStrategiesProcedure, "repository_get_available_chunking_strategies", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceGetHighLevelFolderDescriptionProcedure, "repository_get_high_level_folder_description", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceRepositoryStatusProcedure, "repository_status", server.ConnectUnary(), agentModule),
|
||||
repositoryServiceProcedure(forwarder.RepositoryServiceBatchRepositoryStatusProcedure, "repository_batch_status", server.ConnectUnary(), agentModule),
|
||||
uploadServiceProcedure(forwarder.UploadServiceUploadDocumentationProcedure, "upload_documentation", server.ConnectUnary(), agentModule),
|
||||
uploadServiceProcedure(forwarder.UploadServiceGetDocProcedure, "upload_get_doc", server.ConnectUnary(), agentModule),
|
||||
uploadServiceProcedure(forwarder.UploadServiceGetPagesProcedure, "upload_get_pages", server.ConnectUnary(), agentModule),
|
||||
uploadServiceProcedure(forwarder.UploadServiceUploadedStatusProcedure, "upload_uploaded_status", server.ConnectUnary(), agentModule),
|
||||
server.Any("/aiserver.v1.AiService/*",
|
||||
server.Name("ai_service"),
|
||||
server.HTTP(),
|
||||
server.Local(server.HTTPHandlerAction(agentModule.AiHandler)),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "ai_service",
|
||||
})),
|
||||
server.Local(aiServiceAction),
|
||||
),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.CppService/AvailableModels", "cpp_available_models", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.CppService/AvailableModels", "cpp_available_models", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.CppService/RecordCppFate", "cpp_record_cpp_fate", server.ConnectUnary(), routeDeps),
|
||||
server.Any("/aiserver.v1.CppService/*",
|
||||
server.Name("cpp_service"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "cpp_service",
|
||||
})),
|
||||
server.Local(fallbackForward),
|
||||
),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.FileSyncService/FSSyncFile", "file_sync_sync_file", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.FileSyncService/FSIsEnabledForUser", "file_sync_is_enabled_for_user", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.FileSyncService/FSConfig", "file_sync_config", server.ConnectUnary(), routeDeps),
|
||||
tabServerUpstreamProcedure("/aiserver.v1.FileSyncService/FSUploadFile", "file_sync_upload_file", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.FileSyncService/FSSyncFile", "file_sync_sync_file", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.FileSyncService/FSIsEnabledForUser", "file_sync_is_enabled_for_user", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.FileSyncService/FSConfig", "file_sync_config", server.ConnectUnary(), routeDeps),
|
||||
tabServerProcedure("/aiserver.v1.FileSyncService/FSUploadFile", "file_sync_upload_file", server.ConnectUnary(), routeDeps),
|
||||
server.Any("/aiserver.v1.FileSyncService/*",
|
||||
server.Name("file_sync"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "file_sync",
|
||||
})),
|
||||
server.Local(fallbackForward),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTokenUsage",
|
||||
server.Name("dashboard_token_usage"),
|
||||
server.HTTP(),
|
||||
server.Local(server.HTTPHandlerAction(agentModule.AiHandler)),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_token_usage",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetGlassEarlyPreviewEnrollment",
|
||||
server.Name("dashboard_glass_early_preview_enrollment"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(server.HTTPHandlerAction(agentModule.AiHandler)),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_glass_early_preview_enrollment",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetCurrentPeriodUsage",
|
||||
server.Name("dashboard_current_period_usage"),
|
||||
@@ -495,9 +567,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetCurrentPeriodUsageResponse",
|
||||
MockBuilder: upstream.DashboardCurrentPeriodUsageMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_current_period_usage",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTeams",
|
||||
server.Name("dashboard_get_teams"),
|
||||
@@ -508,47 +577,25 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetTeamsResponse",
|
||||
MockBuilder: upstream.DashboardTeamsMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_teams",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetManagedSkills",
|
||||
server.Name("dashboard_get_managed_skills"),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTeamAdminSettingsOrEmptyIfNotInTeam",
|
||||
server.Name("dashboard_get_team_admin_settings_or_empty"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_managed_skills",
|
||||
Name: "dashboard_get_team_admin_settings_or_empty",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetManagedSkillsResponse",
|
||||
MockBuilder: upstream.DashboardManagedSkillsMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_managed_skills",
|
||||
MockProtoType: "aiserver.v1.GetTeamAdminSettingsResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetMe",
|
||||
server.Name("dashboard_get_me"),
|
||||
server.POST("/aiserver.v1.DashboardService/GetTeamReposOrEmptyIfNotInTeam",
|
||||
server.Name("dashboard_get_team_repos_or_empty"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_me",
|
||||
Name: "dashboard_get_team_repos_or_empty",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetMeResponse",
|
||||
MockBuilder: upstream.DashboardGetMeMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_get_me",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetUserPrivacyMode",
|
||||
server.Name("dashboard_user_privacy_mode"),
|
||||
server.ConnectUnary(),
|
||||
server.Local(upstream.MockProtoAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_user_privacy_mode",
|
||||
StatusCode: http.StatusOK,
|
||||
MockProtoType: "aiserver.v1.GetUserPrivacyModeResponse",
|
||||
MockBuilder: upstream.DashboardUserPrivacyModeMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_user_privacy_mode",
|
||||
MockProtoType: "aiserver.v1.GetTeamReposResponse",
|
||||
MockBuilder: upstream.EmptyMockBuilder,
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetPlanInfo",
|
||||
@@ -560,9 +607,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetPlanInfoResponse",
|
||||
MockBuilder: upstream.DashboardPlanInfoMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_plan_info",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants",
|
||||
server.Name("dashboard_usage_limit_status"),
|
||||
@@ -573,9 +617,6 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.GetUsageLimitStatusAndActiveGrantsResponse",
|
||||
MockBuilder: upstream.DashboardUsageLimitStatusAndActiveGrantsMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_usage_limit_status",
|
||||
})),
|
||||
),
|
||||
server.POST("/aiserver.v1.DashboardService/IsOnNewPricing",
|
||||
server.Name("dashboard_is_on_new_pricing"),
|
||||
@@ -586,161 +627,57 @@ func (host *Host) rebuildLocked(cfg serverconfig.Config) error {
|
||||
MockProtoType: "aiserver.v1.IsOnNewPricingResponse",
|
||||
MockBuilder: upstream.DashboardIsOnNewPricingMockBuilder,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard_is_on_new_pricing",
|
||||
})),
|
||||
),
|
||||
// tabServerUpstreamProcedure("/aiserver.v1.DashboardService/GetEffectiveUserPlugins", "dashboard_get_effective_user_plugins", server.ConnectUnary(), routeDeps),
|
||||
server.Any("/aiserver.v1.DashboardService/*",
|
||||
server.Name("dashboard"),
|
||||
server.Any("/*",
|
||||
server.Name("upstream_fallback"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "dashboard",
|
||||
})),
|
||||
server.Local(fallbackForward),
|
||||
),
|
||||
server.Any("/aiserver.v1.NetworkService/*",
|
||||
server.Name("network_service"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "network_service",
|
||||
})),
|
||||
),
|
||||
server.Any("/aiserver.v1.InAppAdService/*",
|
||||
server.Name("in_app_ad"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "in_app_ad",
|
||||
})),
|
||||
),
|
||||
server.GET("/auth/full_stripe_profile",
|
||||
server.Name("auth_full_stripe_profile"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockAuthFullStripeProfileAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_full_stripe_profile",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_full_stripe_profile",
|
||||
})),
|
||||
),
|
||||
server.GET("/auth/stripe_profile",
|
||||
server.Name("auth_stripe_profile"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockAuthStripeProfileAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_stripe_profile",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_stripe_profile",
|
||||
})),
|
||||
),
|
||||
server.GET("/auth/has_valid_payment_method",
|
||||
server.Name("auth_has_valid_payment_method"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockJSONAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_has_valid_payment_method",
|
||||
StatusCode: http.StatusOK,
|
||||
JSONBody: map[string]any{
|
||||
"hasValidPaymentMethod": true,
|
||||
},
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_has_valid_payment_method",
|
||||
})),
|
||||
),
|
||||
server.Any("/auth/poll",
|
||||
server.Name("auth_poll"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.MockAuthPollAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_poll",
|
||||
StatusCode: http.StatusOK,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_poll",
|
||||
})),
|
||||
),
|
||||
server.POST("/auth/logout",
|
||||
server.Name("auth_logout"),
|
||||
server.HTTP(),
|
||||
server.Local(upstream.FixedStatusAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_logout",
|
||||
StatusCode: http.StatusNoContent,
|
||||
})),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_logout",
|
||||
})),
|
||||
),
|
||||
server.Any("/auth/*",
|
||||
server.Name("auth_proxy"),
|
||||
server.HTTP(),
|
||||
server.Local(func(ctx *server.Context) error {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}),
|
||||
server.Upstream(upstream.DirectAction(routeDeps, upstream.CompatRouteConfig{
|
||||
Name: "auth_proxy",
|
||||
})),
|
||||
),
|
||||
)
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func directUpstreamProcedure(pattern string, name string, protocol server.RouteOption, deps upstream.Dependencies) server.Option {
|
||||
direct := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
action := func(ctx *server.Context) error {
|
||||
if ctx != nil && ctx.UpstreamURL == nil && ctx.Request != nil && ctx.Request.URL != nil {
|
||||
targetURL := *ctx.Request.URL
|
||||
targetURL.Scheme = "https"
|
||||
targetURL.Host = "api2.cursor.sh:443"
|
||||
ctx.UpstreamURL = &targetURL
|
||||
func withLocalBackendCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
writer.Header().Del("Access-Control-Allow-Credentials")
|
||||
if strings.EqualFold(request.Method, http.MethodOptions) && strings.TrimSpace(request.Header.Get("Access-Control-Request-Method")) != "" {
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
requestedHeaders := strings.TrimSpace(request.Header.Get("Access-Control-Request-Headers"))
|
||||
if requestedHeaders == "" {
|
||||
requestedHeaders = "authorization,content-type,x-cursor-client-type"
|
||||
}
|
||||
return direct(ctx)
|
||||
}
|
||||
return server.POST(pattern,
|
||||
server.Name(name),
|
||||
protocol,
|
||||
server.Local(action),
|
||||
server.Upstream(action),
|
||||
)
|
||||
writer.Header().Set("Access-Control-Allow-Headers", requestedHeaders)
|
||||
writer.Header().Set("Access-Control-Max-Age", "86400")
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
func repositoryServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module, deps upstream.Dependencies) server.Option {
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
func repositoryServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module) server.Option {
|
||||
localAction := server.HTTPHandlerAction(module.RepositoryServiceHandler)
|
||||
upstreamAction := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
return server.POST(pattern,
|
||||
server.Name(name),
|
||||
protocol,
|
||||
server.Local(localAction),
|
||||
server.Upstream(upstreamAction),
|
||||
)
|
||||
}
|
||||
|
||||
func uploadServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module, deps upstream.Dependencies) server.Option {
|
||||
func uploadServiceProcedure(pattern string, name string, protocol server.RouteOption, module *forwarder.Module) server.Option {
|
||||
localAction := server.HTTPHandlerAction(module.UploadServiceHandler)
|
||||
upstreamAction := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
return server.POST(pattern,
|
||||
server.Name(name),
|
||||
protocol,
|
||||
server.Local(localAction),
|
||||
server.Upstream(upstreamAction),
|
||||
)
|
||||
}
|
||||
|
||||
func tabServerUpstreamProcedure(pattern string, name string, protocol server.RouteOption, deps upstream.Dependencies) server.Option {
|
||||
direct := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
func tabServerProcedure(pattern string, name string, protocol server.RouteOption, deps upstream.Dependencies) server.Option {
|
||||
forward := upstream.ForwardAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
action := func(ctx *server.Context) error {
|
||||
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
|
||||
baseURL, err := url.Parse(tabServerBaseURL)
|
||||
@@ -752,13 +689,12 @@ func tabServerUpstreamProcedure(pattern string, name string, protocol server.Rou
|
||||
targetURL.Host = baseURL.Host
|
||||
ctx.UpstreamURL = &targetURL
|
||||
}
|
||||
return direct(ctx)
|
||||
return forward(ctx)
|
||||
}
|
||||
return server.POST(pattern,
|
||||
server.Name(name),
|
||||
protocol,
|
||||
server.Local(action),
|
||||
server.Upstream(action),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cursor/gen/aiserverv1"
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
"cursor/internal/certs"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestHostServesDevLoginAndLocalTeamsRoute(t *testing.T) {
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
host, err := NewHost(store)
|
||||
if err != nil {
|
||||
t.Fatalf("new host: %v", err)
|
||||
}
|
||||
|
||||
loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=enterprise&email=enterprise%40example.com", nil)
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(loginRecorder, loginRequest)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("dev login status: got %d, want %d; body=%s", loginRecorder.Code, http.StatusOK, loginRecorder.Body.String())
|
||||
}
|
||||
var loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
|
||||
t.Fatalf("decode dev login: %v", err)
|
||||
}
|
||||
if loginResponse.AccessToken == "" {
|
||||
t.Fatal("dev login returned an empty access token")
|
||||
}
|
||||
|
||||
teamsRequest := httptest.NewRequest(http.MethodPost, "http://local/aiserver.v1.DashboardService/GetTeams", nil)
|
||||
teamsRequest.Header.Set("Authorization", "Bearer "+loginResponse.AccessToken)
|
||||
teamsRecorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(teamsRecorder, teamsRequest)
|
||||
if teamsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("teams status: got %d, want %d", teamsRecorder.Code, http.StatusOK)
|
||||
}
|
||||
teams := &aiserverv1.GetTeamsResponse{}
|
||||
if err := proto.Unmarshal(teamsRecorder.Body.Bytes(), teams); err != nil {
|
||||
t.Fatalf("decode teams response: %v", err)
|
||||
}
|
||||
if len(teams.GetTeams()) != 1 || !teams.GetTeams()[0].GetIsEnterprise() {
|
||||
t.Fatalf("unexpected teams response: %v", teams.GetTeams())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowsWildcardCORS(t *testing.T) {
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
host, err := NewHost(store)
|
||||
if err != nil {
|
||||
t.Fatalf("new host: %v", err)
|
||||
}
|
||||
|
||||
preflightRequest := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token?plan=free", nil)
|
||||
preflightRequest.Header.Set("Origin", "vscode-file://vscode-app")
|
||||
preflightRequest.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
preflightRequest.Header.Set("Access-Control-Request-Headers", "x-cursor-client-type")
|
||||
preflightRecorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(preflightRecorder, preflightRequest)
|
||||
if preflightRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status: got %d, want %d", preflightRecorder.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := preflightRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf("preflight allow origin: got %q", got)
|
||||
}
|
||||
if got := preflightRecorder.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Fatalf("preflight allow credentials: got %q, want empty", got)
|
||||
}
|
||||
if got := preflightRecorder.Header().Get("Access-Control-Allow-Headers"); got != "x-cursor-client-type" {
|
||||
t.Fatalf("preflight allow headers: got %q", got)
|
||||
}
|
||||
|
||||
loginRequest := httptest.NewRequest(http.MethodGet, "http://local/auth/cursor_dev_session_token?plan=free", nil)
|
||||
loginRequest.Header.Set("Origin", "vscode-file://vscode-app")
|
||||
loginRequest.Header.Set("x-cursor-client-type", "ide")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(loginRecorder, loginRequest)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("dev login status: got %d, want %d", loginRecorder.Code, http.StatusOK)
|
||||
}
|
||||
if got := loginRecorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf("dev login allow origin: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowsRemoteWebOriginWithWildcard(t *testing.T) {
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
host, err := NewHost(store)
|
||||
if err != nil {
|
||||
t.Fatalf("new host: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodOptions, "http://local/auth/cursor_dev_session_token", nil)
|
||||
request.Header.Set("Origin", "https://example.com")
|
||||
request.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
recorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(recorder, request)
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Fatalf("remote origin allow origin: got %q, want wildcard", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostServesDevLoginOverTrustedLocalhostTLS(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve backend port: %v", err)
|
||||
}
|
||||
listenAddr := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("release backend port: %v", err)
|
||||
}
|
||||
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
config := serverconfig.DefaultConfig()
|
||||
config.BackendListenAddr = listenAddr
|
||||
if _, err := store.Save(context.Background(), config); err != nil {
|
||||
t.Fatalf("save backend config: %v", err)
|
||||
}
|
||||
certificateManager, err := certs.NewEmbeddedManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new certificate manager: %v", err)
|
||||
}
|
||||
serverCertificate, err := certificateManager.CertificateForServerName("localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("create localhost certificate: %v", err)
|
||||
}
|
||||
host, err := NewHost(store, WithTLSCertificate(serverCertificate))
|
||||
if err != nil {
|
||||
t.Fatalf("new TLS host: %v", err)
|
||||
}
|
||||
if err := host.Start(); err != nil {
|
||||
t.Fatalf("start TLS host: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := host.Stop(stopContext); err != nil {
|
||||
t.Errorf("stop TLS host: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
caCertificate, err := certificateManager.CATLSCertificate()
|
||||
if err != nil {
|
||||
t.Fatalf("load CA certificate: %v", err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(caCertificate.Leaf)
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: roots,
|
||||
ServerName: "localhost",
|
||||
}}}
|
||||
response, err := client.Get(host.BaseURL() + "/auth/cursor_dev_session_token?plan=pro&trial=true")
|
||||
if err != nil {
|
||||
t.Fatalf("request dev login over TLS: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dev login TLS status: got %d, want %d", response.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"cursor/internal/backend/server"
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
)
|
||||
|
||||
func TestHostForwardsUnhandledRoutesToOriginalUpstream(t *testing.T) {
|
||||
var requestCount atomic.Int32
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestCount.Add(1)
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read upstream request body: %v", err)
|
||||
}
|
||||
writer.Header().Set("X-Upstream-Path", request.URL.RequestURI())
|
||||
writer.WriteHeader(http.StatusMultiStatus)
|
||||
_, _ = writer.Write(body)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
host, err := NewHost(store)
|
||||
if err != nil {
|
||||
t.Fatalf("new host: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{name: "managed skills", path: "/aiserver.v1.DashboardService/GetManagedSkills?source=skills"},
|
||||
{name: "effective plugins", path: "/aiserver.v1.DashboardService/GetEffectiveUserPlugins?source=plugins"},
|
||||
{name: "MCP registry", path: "/aiserver.v1.MCPRegistryService/GetKnownServers?source=mcp"},
|
||||
{name: "auth poll", path: "/auth/poll?uuid=local-login&verifier=test"},
|
||||
{name: "OAuth token", path: "/oauth/token"},
|
||||
{name: "auth email", path: "/aiserver.v1.AuthService/GetEmail"},
|
||||
{name: "dashboard me", path: "/aiserver.v1.DashboardService/GetMe"},
|
||||
{name: "full stripe profile", method: http.MethodGet, path: "/auth/full_stripe_profile"},
|
||||
{name: "stripe profile", method: http.MethodGet, path: "/auth/stripe_profile"},
|
||||
{name: "valid payment method", method: http.MethodGet, path: "/auth/has_valid_payment_method"},
|
||||
{name: "auth logout", path: "/auth/logout"},
|
||||
{name: "dashboard global commands", path: "/aiserver.v1.DashboardService/GetGlobalCommands"},
|
||||
{name: "dashboard CLI download", path: "/aiserver.v1.DashboardService/GetCliDownloadUrl"},
|
||||
{name: "dashboard privacy mode", path: "/aiserver.v1.DashboardService/GetUserPrivacyMode"},
|
||||
{name: "service catch-all", path: "/aiserver.v1.NetworkService/UnknownProcedure?source=network"},
|
||||
{name: "AI handler miss", path: "/aiserver.v1.AiService/UnknownProcedure?source=ai"},
|
||||
{name: "global miss", path: "/unknown/service/path?source=global"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
method := testCase.method
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
body := "payload-" + testCase.name
|
||||
request := httptest.NewRequest(method, "http://localhost:8000"+testCase.path, strings.NewReader(body))
|
||||
request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+testCase.path)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
host.mux.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Code; got != http.StatusMultiStatus {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", got, http.StatusMultiStatus, recorder.Body.String())
|
||||
}
|
||||
if got := recorder.Header().Get("X-Upstream-Path"); got != testCase.path {
|
||||
t.Fatalf("upstream path: got %q, want %q", got, testCase.path)
|
||||
}
|
||||
wantBody := body
|
||||
if method == http.MethodGet {
|
||||
wantBody = ""
|
||||
}
|
||||
if got := recorder.Body.String(); got != wantBody {
|
||||
t.Fatalf("response body: got %q, want %q", got, wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
requestsBeforeHealthCheck := requestCount.Load()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "http://localhost:8000"+healthPath, nil)
|
||||
healthRecorder := httptest.NewRecorder()
|
||||
host.mux.ServeHTTP(healthRecorder, healthRequest)
|
||||
if got := healthRecorder.Code; got != http.StatusOK {
|
||||
t.Fatalf("health status: got %d, want %d", got, http.StatusOK)
|
||||
}
|
||||
if got := requestCount.Load(); got != requestsBeforeHealthCheck {
|
||||
t.Fatalf("local health route unexpectedly reached upstream: requests before=%d after=%d", requestsBeforeHealthCheck, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFallbackKeepsWildcardCORSWhenUpstreamReturnsCORSHeaders(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Access-Control-Allow-Origin", "vscode-file://vscode-app")
|
||||
writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), t.TempDir())
|
||||
host, err := NewHost(store)
|
||||
if err != nil {
|
||||
t.Fatalf("new host: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "http://localhost:8000/auth/poll?uuid=test", nil)
|
||||
request.Header.Set("Origin", "vscode-file://vscode-app")
|
||||
request.Header.Set(server.HeaderServerUpstreamURL, upstreamServer.URL+request.URL.RequestURI())
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
host.mux.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Header().Values("Access-Control-Allow-Origin"); len(got) != 1 || got[0] != "*" {
|
||||
t.Fatalf("allow origin values: got %q, want [*]", got)
|
||||
}
|
||||
if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Fatalf("allow credentials: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user