mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1242346404 | ||
|
|
89898a602e | ||
|
|
cf1332aaa0 | ||
|
|
f50c855083 | ||
|
|
2138bb0521 | ||
|
|
fef84da85e | ||
|
|
9e6c29b13e | ||
|
|
ee10eaeba7 | ||
|
|
4a13f565f6 | ||
|
|
b6dc8cf3c3 |
-26
@@ -1,26 +0,0 @@
|
||||
claude-server.tar
|
||||
.DS_Store
|
||||
dist
|
||||
.task
|
||||
bin
|
||||
logs/
|
||||
gen/
|
||||
logs.zip
|
||||
frontend/bindings
|
||||
dist
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
cursor-server.tar
|
||||
/cursor-server/target/
|
||||
/cursor-server/*.db
|
||||
/cursor-server/*.db-shm
|
||||
/cursor-server/*.db-wal
|
||||
server-node/cursor.tar
|
||||
server-go/cursor.tar
|
||||
server-go/log/
|
||||
.cursor-local-assistant
|
||||
.cursor-local-assistant-v2
|
||||
.cursor-app-formatted/
|
||||
proto/extensions-cursor-app/
|
||||
cursor-tab-server/cursor-tab-server-linux-amd64.tar
|
||||
/cursor-proto/proto/
|
||||
@@ -1,9 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
|
||||
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
|
||||
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
|
||||
- Keep components modular and concerns clearly separated.
|
||||
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
|
||||
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
|
||||
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 leookun
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,87 +1,16 @@
|
||||
# cursor-byok
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/2e1710b0-cdbd-4576-bd24-1614df016219" />
|
||||
|
||||
`cursor-byok` 是一个基于真实 Cursor Agent 流量与 protobuf 实现的自托管服务端,用于把 Cursor 客户端接入用户指定的 LLM Provider。
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/00885453-6a91-4052-aadf-f686daeec881" />
|
||||
|
||||
当前 Rust 服务 `cursor-server` 已实现:
|
||||
<img width="820" alt="image" src="https://github.com/user-attachments/assets/a607be84-a738-4e33-9750-13352e74001c" />
|
||||
|
||||
- Cursor `RunSSE + BidiAppend` 双向协议与 Connect envelope。
|
||||
- OpenAI Chat、OpenAI Responses、Anthropic 三种无状态流式端点。
|
||||
- `LLM → 客户端工具 → 结果提交 → 下一轮 LLM` 的通用 Loop。
|
||||
- append-only canonical messages、不可变 revision 分支和同对话 Run 抢占。
|
||||
- Cursor typed tool UI、Exec/Interaction、多阶段编辑、MCP 与子代理。
|
||||
- Blob CAS、KV GET/SET ACK、两阶段 checkpoint 和 pending ToolRound 恢复。
|
||||
- 未匹配 Cursor backend 路由原样流式转发到上游。
|
||||
- React + Vite + TypeScript + Tailwind 管理台,支持 Provider 配置、模型发现和调用明细。
|
||||
- 每次 Provider 调用的时间、模型快照、状态和 authoritative usage;详细模式保存脱敏请求与原始流响应。
|
||||
|
||||
启动方式见 [cursor-server/README.md](./cursor-server/README.md)。协议证据见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md),当前实现约束见 [一次性重构计划计划.md](./docs/一次性重构计划计划.md)。
|
||||
## Star History
|
||||
|
||||
## 核心数据流
|
||||
|
||||
```text
|
||||
HTTP / Connect
|
||||
↓
|
||||
Cursor adapter
|
||||
↓ ClientCommand / ClientEvent
|
||||
RunEngine
|
||||
↓
|
||||
canonical messages + selected revision
|
||||
↓
|
||||
typed ModelRequest
|
||||
↓
|
||||
Provider adapter → HTTP/SSE → ModelEvent
|
||||
```
|
||||
|
||||
Loop 不依赖 Cursor protobuf、Blob、checkpoint、数字 wire id 或具体 Provider JSON。Cursor adapter 和 Provider adapter 只在各自边界做协议投射。
|
||||
|
||||
## 状态与 checkpoint
|
||||
|
||||
- SQLite 中的 immutable messages 与 revision 父链是对话事实源;回滚只选择旧 revision 并建立新分支。
|
||||
- ToolRound 保存完整 assistant、原始 call 顺序和真实 result 完成顺序;结果未齐时不会把悬空 tool call 投给下一轮模型。
|
||||
- BlobID 是 `SHA-256(data)`;Blob 类型来自引用字段,不编码在 ID 中。
|
||||
- checkpoint 引用的新 Blob 必须先收到对应 KV SET ACK。协议中不存在 checkpoint ACK,也不保存跨流 outbox。
|
||||
- staged checkpoint 内联完整 pending assistant;ToolRound 全部结果提交后才折叠进 stable roots。抓包没有单 ToolResult checkpoint,因此实现也不制造该状态。
|
||||
- settled checkpoint 必须先于下一轮 LLM 调用。最终文本轮严格发送 `turn_ended → staged → settled → settled 重发 → EndStream`。
|
||||
- Cursor.app 只把 `turn_ended` 前的 checkpoint 作为自动恢复候选;恢复 pending assistant 时先继续工具,不重复调用 LLM。
|
||||
|
||||
## 目录边界
|
||||
|
||||
```text
|
||||
cursor-server/src/
|
||||
├── control/ # 客户端无关的 Provider、模型、调用观测 HTTP API
|
||||
├── client/ # 所有客户端共用的最小 command/event port
|
||||
├── model/ # canonical message、revision、ModelSpec、typed history
|
||||
├── run/ # 协议无关 Loop、ModelCycle、ToolRound 和 RunRegistry
|
||||
├── provider/ # 各 LLM 端点请求与流事件适配
|
||||
├── cursor/
|
||||
│ ├── request/ # RunRequest → PreparedRun/CursorRunContext
|
||||
│ ├── prompting/ # Cursor prompt、工具 catalog 和 mode manifest
|
||||
│ ├── projection/ # Cursor AI-SDK stable/pending JSON 编解码
|
||||
│ ├── interaction/ # UI 更新、InteractionQuery、typed ToolCall 渲染
|
||||
│ ├── tools/ # Cursor 工具 transport、runtime、dispatch 和 result
|
||||
│ └── checkpoint/ # root/Turn/derived/recovery 与串行 worker
|
||||
└── store/ # SQLite messages、revision、ToolRound、Run 和 Blob CAS
|
||||
|
||||
console/ # React 管理台;只依赖 control API,不依赖 Cursor protobuf
|
||||
```
|
||||
|
||||
详细到文件的目标目录和验收项只在重构计划中维护,README 不复制第二份易漂移的完整文件清单。
|
||||
|
||||
## 工程原则
|
||||
|
||||
- 不保留旧路径、兼容层或失败后的隐式 fallback。
|
||||
- 同一状态只有一个所有者;协议层不做 Loop 决策。
|
||||
- PromptSpec、ModelSpec 和 selected revision 决定可重放的 ModelRequest;request id、时间和 model call id 不进入模型输入。
|
||||
- 前缀稳定限定在相同 PromptSpec/ModelSpec/Provider route;新 Run 切换模型或模式时只替换 Cursor system root,其他历史 message roots 继续复用。
|
||||
- Provider replay state 只回传给产生它的端点;可展示 thinking 不是跨端点 reasoning 字段。
|
||||
- Provider usage 只采用端点报告的单轮最终值,不自行估算。
|
||||
- 同一个取消信号覆盖等待 HTTP 响应头和读取 SSE 两段。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd cursor-server
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
<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>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cursor BYOK</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-2533
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "cursor-byok-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { CallDetail, LlmCall, Provider, ProviderInput, ProviderModel } from './types'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }))
|
||||
throw new Error(error.message ?? `HTTP ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
providers: () => request<Provider[]>('/api/providers'),
|
||||
createProvider: (input: ProviderInput) =>
|
||||
request<Provider>('/api/providers', { method: 'POST', body: JSON.stringify(input) }),
|
||||
updateProvider: (id: number, input: ProviderInput) =>
|
||||
request<Provider>(`/api/providers/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: number) => request<void>(`/api/providers/${id}`, { method: 'DELETE' }),
|
||||
discoverModels: (id: number) =>
|
||||
request<{ models: string[] }>(`/api/providers/${id}/models/discover`, { method: 'POST' }),
|
||||
saveModels: (id: number, models: unknown[]) =>
|
||||
request<ProviderModel[]>(`/api/providers/${id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ models }),
|
||||
}),
|
||||
models: () => request<ProviderModel[]>('/api/models'),
|
||||
deleteModel: (hash: string) => request<void>(`/api/models/${hash}`, { method: 'DELETE' }),
|
||||
calls: () => request<LlmCall[]>('/api/llm-calls'),
|
||||
call: (id: string) => request<CallDetail>(`/api/llm-calls/${encodeURIComponent(id)}`),
|
||||
observability: () => request<{ detailed: boolean }>('/api/settings/observability'),
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>('/api/settings/observability', {
|
||||
method: 'PUT', body: JSON.stringify({ detailed }),
|
||||
}),
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export type ProviderType = 'openai-chat' | 'openai-responses' | 'anthropic'
|
||||
|
||||
export interface Provider {
|
||||
provider_id: number
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
has_api_key: boolean
|
||||
custom_headers: Record<string, string | null>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
api_key?: string
|
||||
custom_headers: Record<string, string | null>
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
model_hash: string
|
||||
provider_id: number
|
||||
model_id: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
sort_order: number
|
||||
context_window_tokens?: number
|
||||
max_output_tokens?: number
|
||||
reasoning_enabled: boolean
|
||||
reasoning_effort?: string
|
||||
extra_params: Record<string, unknown>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface LlmCall {
|
||||
call_id: string
|
||||
run_id: string
|
||||
conversation_id: string
|
||||
model_hash?: string
|
||||
model_id: string
|
||||
display_name: string
|
||||
provider_type: ProviderType
|
||||
status: string
|
||||
created_at_ms: number
|
||||
duration_ms?: number
|
||||
ttfb_ms?: number
|
||||
ttft_ms?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
detailed: boolean
|
||||
}
|
||||
|
||||
export interface CallDetail {
|
||||
call: LlmCall
|
||||
request?: { headers: Record<string, string>; body: unknown; byte_count: number }
|
||||
response_chunks: { seq: number; received_offset_ms: number; data: string; byte_count: number }[]
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { NavLink, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { CallDetailPage } from '../features/calls/CallDetailPage'
|
||||
import { CallsPage } from '../features/calls/CallsPage'
|
||||
import { ModelsPage } from '../features/models/ModelsPage'
|
||||
import { ProvidersPage } from '../features/providers/ProvidersPage'
|
||||
import { ObservabilityPage } from '../features/settings/ObservabilityPage'
|
||||
|
||||
const links = [
|
||||
['/', 'Providers'],
|
||||
['/models', 'Models'],
|
||||
['/calls', 'LLM Calls'],
|
||||
['/settings', 'Settings'],
|
||||
] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="border-b border-zinc-800 bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-8 px-6 py-4">
|
||||
<div className="text-lg font-semibold">Cursor BYOK</div>
|
||||
<nav className="flex gap-2">
|
||||
{links.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm ${isActive ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white'}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProvidersPage />} />
|
||||
<Route path="/models" element={<ModelsPage />} />
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:callId" element={<CallDetailPage />} />
|
||||
<Route path="/settings" element={<ObservabilityPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function CallDetailPage() {
|
||||
const { callId = '' } = useParams()
|
||||
const detail = useQuery({ queryKey: ['call', callId], queryFn: () => api.call(callId) })
|
||||
if (!detail.data) return <p>加载中…</p>
|
||||
const { call, request, response_chunks: chunks } = detail.data
|
||||
return <section className="grid gap-6">
|
||||
<div><h1>{call.display_name}</h1><p><code>{call.call_id}</code> · {call.status}</p></div>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<Metric label="TTFB" value={call.ttfb_ms} suffix="ms" /><Metric label="TTFT" value={call.ttft_ms} suffix="ms" />
|
||||
<Metric label="Duration" value={call.duration_ms} suffix="ms" /><Metric label="Total tokens" value={call.total_tokens} />
|
||||
</div>
|
||||
<Payload title="Request" value={request ?? '详细模式未记录'} />
|
||||
<Payload title="Response stream" value={chunks.length ? chunks : '详细模式未记录'} />
|
||||
</section>
|
||||
}
|
||||
|
||||
function Metric({ label, value, suffix = '' }: { label: string; value?: number; suffix?: string }) {
|
||||
return <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"><p>{label}</p><div className="mt-2 text-xl">{value ?? '—'} {value == null ? '' : suffix}</div></div>
|
||||
}
|
||||
|
||||
function Payload({ title, value }: { title: string; value: unknown }) {
|
||||
return <div><h2>{title}</h2><pre className="mt-2 max-h-[32rem] overflow-auto rounded-xl border border-zinc-800 bg-black p-4 text-xs text-zinc-300">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function CallsPage() {
|
||||
const calls = useQuery({ queryKey: ['calls'], queryFn: api.calls, refetchInterval: 3000 })
|
||||
return <section><div><h1>LLM Calls</h1><p>每一行对应一次真实 Provider 请求。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>时间</th><th>模型</th><th>状态</th><th>TTFT</th><th>耗时</th><th>Tokens</th></tr></thead>
|
||||
<tbody>{calls.data?.map((call) => <tr key={call.call_id}>
|
||||
<td><Link className="text-blue-400 hover:underline" to={`/calls/${encodeURIComponent(call.call_id)}`}>{new Date(call.created_at_ms).toLocaleString()}</Link></td><td>{call.display_name}<small>{call.model_id}</small></td><td>{call.status}</td>
|
||||
<td>{call.ttft_ms == null ? '—' : `${call.ttft_ms} ms`}</td><td>{call.duration_ms == null ? '—' : `${call.duration_ms} ms`}</td><td>{call.total_tokens ?? '—'}</td>
|
||||
</tr>)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import type { ProviderModel } from '../../api/types'
|
||||
|
||||
export function ModelsPage() {
|
||||
const client = useQueryClient()
|
||||
const models = useQuery({ queryKey: ['models'], queryFn: api.models })
|
||||
const save = useMutation({
|
||||
mutationFn: ({ model, input }: { model: ProviderModel; input: ModelEdit }) =>
|
||||
api.saveModels(model.provider_id, [{
|
||||
model_id: model.model_id, display_name: input.displayName, enabled: input.enabled, sort_order: model.sort_order,
|
||||
context_window_tokens: input.contextWindow || undefined, max_output_tokens: input.maxOutput || undefined,
|
||||
reasoning_enabled: input.reasoning, reasoning_effort: input.effort || undefined,
|
||||
extra_params: model.extra_params,
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
return <section><div><h1>Models</h1><p>Hash 是 Cursor 和其他客户端使用的稳定公开标识。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>Hash / Provider ID</th><th>Display name</th><th>Context</th><th>Max output</th><th>Reasoning</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>{models.data?.map((model) => <ModelRow key={model.model_hash} model={model} onSave={(input) => save.mutate({ model, input })} />)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
interface ModelEdit { displayName: string; enabled: boolean; contextWindow: number; maxOutput: number; reasoning: boolean; effort: string }
|
||||
|
||||
function ModelRow({ model, onSave }: { model: ProviderModel; onSave: (input: ModelEdit) => void }) {
|
||||
const [displayName, setDisplayName] = useState(model.display_name)
|
||||
const [contextWindow, setContextWindow] = useState(model.context_window_tokens ?? 0)
|
||||
const [maxOutput, setMaxOutput] = useState(model.max_output_tokens ?? 0)
|
||||
const [reasoning, setReasoning] = useState(model.reasoning_enabled)
|
||||
const [effort, setEffort] = useState(model.reasoning_effort ?? '')
|
||||
const value = (enabled: boolean): ModelEdit => ({ displayName, enabled, contextWindow, maxOutput, reasoning, effort })
|
||||
return <tr>
|
||||
<td><code>{model.model_hash}</code><small>{model.model_id}</small></td>
|
||||
<td><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></td>
|
||||
<td><input type="number" value={contextWindow || ''} onChange={(event) => setContextWindow(Number(event.target.value))} /></td>
|
||||
<td><input type="number" value={maxOutput || ''} onChange={(event) => setMaxOutput(Number(event.target.value))} /></td>
|
||||
<td><div className="flex items-center gap-2"><input className="h-4 w-4" type="checkbox" checked={reasoning} onChange={(event) => setReasoning(event.target.checked)} /><input placeholder="effort" value={effort} onChange={(event) => setEffort(event.target.value)} /></div></td>
|
||||
<td>{model.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
<td><div className="flex gap-2"><button onClick={() => onSave(value(model.enabled))}>保存</button><button onClick={() => onSave(value(!model.enabled))}>{model.enabled ? '停用' : '启用'}</button></div></td>
|
||||
</tr>
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { Provider, ProviderInput, ProviderType } from '../../api/types'
|
||||
|
||||
export function ProviderForm({ provider, onSave, busy }: { provider?: Provider; onSave: (value: ProviderInput) => void; busy: boolean }) {
|
||||
const [name, setName] = useState(provider?.name ?? '')
|
||||
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type ?? 'openai-chat')
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.base_url ?? 'https://api.openai.com/v1')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [headers, setHeaders] = useState(JSON.stringify(provider?.custom_headers ?? {}, null, 2))
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 md:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSave({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey || undefined, custom_headers: JSON.parse(headers) })
|
||||
}}
|
||||
>
|
||||
<Field label="名称"><input value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
<Field label="类型">
|
||||
<select value={providerType} onChange={(e) => setProviderType(e.target.value as ProviderType)}>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Base URL"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} required /></Field>
|
||||
<Field label="API Key"><input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></Field>
|
||||
<Field label="Custom headers (JSON)"><textarea value={headers} onChange={(e) => setHeaders(e.target.value)} /></Field>
|
||||
<div className="md:col-span-2"><button disabled={busy}>{busy ? '保存中…' : provider ? '保存 Provider' : '添加 Provider'}</button></div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: React.PropsWithChildren<{ label: string }>) {
|
||||
return <label className="grid gap-2 text-sm text-zinc-400"><span>{label}</span>{children}</label>
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { ProviderForm } from './ProviderForm'
|
||||
import type { Provider } from '../../api/types'
|
||||
|
||||
export function ProvidersPage() {
|
||||
const client = useQueryClient()
|
||||
const providers = useQuery({ queryKey: ['providers'], queryFn: api.providers })
|
||||
const [discoveries, setDiscoveries] = useState<Record<number, string[]>>({})
|
||||
const [editing, setEditing] = useState<Provider>()
|
||||
const create = useMutation({
|
||||
mutationFn: api.createProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, value }: { id: number; value: Parameters<typeof api.updateProvider>[1] }) => api.updateProvider(id, value),
|
||||
onSuccess: () => { setEditing(undefined); client.invalidateQueries({ queryKey: ['providers'] }) },
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.deleteProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const discover = useMutation({
|
||||
mutationFn: api.discoverModels,
|
||||
onSuccess: (result, id) => setDiscoveries((current) => ({ ...current, [id]: result.models })),
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: ({ id, model }: { id: number; model: string }) => api.saveModels(id, [{
|
||||
model_id: model, display_name: model, enabled: true, sort_order: 0,
|
||||
reasoning_enabled: false, extra_params: {},
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="grid gap-8">
|
||||
<div><h1>Provider</h1><p>配置端点并从 Provider 拉取可用模型。</p></div>
|
||||
<ProviderForm key={editing?.provider_id ?? 'new'} provider={editing} onSave={(value) => editing
|
||||
? update.mutate({ id: editing.provider_id, value })
|
||||
: create.mutate(value)} busy={create.isPending || update.isPending} />
|
||||
<div className="grid gap-4">
|
||||
{providers.data?.map((provider) => (
|
||||
<article key={provider.provider_id} className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div><h2>{provider.name}</h2><p>{provider.provider_type} · {provider.base_url}</p></div>
|
||||
<div className="flex gap-2"><button onClick={() => setEditing(provider)}>编辑</button><button onClick={() => discover.mutate(provider.provider_id)}>拉取模型</button><button className="danger" onClick={() => remove.mutate(provider.provider_id)}>删除</button></div>
|
||||
</div>
|
||||
{discoveries[provider.provider_id] && (
|
||||
<div className="mt-4 grid gap-2 border-t border-zinc-800 pt-4">
|
||||
{discoveries[provider.provider_id].map((model) => (
|
||||
<div key={model} className="flex items-center justify-between rounded-md bg-zinc-950 px-3 py-2 text-sm">
|
||||
<code>{model}</code>
|
||||
<button onClick={() => save.mutate({ id: provider.provider_id, model })}>添加</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const client = useQueryClient()
|
||||
const settings = useQuery({ queryKey: ['observability'], queryFn: api.observability })
|
||||
const update = useMutation({ mutationFn: api.setObservability, onSuccess: () => client.invalidateQueries({ queryKey: ['observability'] }) })
|
||||
return <section><h1>Observability</h1><p>概要始终保存;详细模式额外保存脱敏请求和流响应。</p>
|
||||
<label className="mt-6 flex max-w-xl items-center justify-between rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<span><strong>详细记录</strong><p>仅影响开启后的新调用。</p></span>
|
||||
<input className="h-5 w-5" type="checkbox" checked={settings.data?.detailed ?? false} onChange={(event) => update.mutate(event.target.checked)} />
|
||||
</label>
|
||||
</section>
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import { App } from './app/App'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body { @apply m-0 bg-zinc-950 font-sans text-zinc-100 antialiased; }
|
||||
h1 { @apply text-2xl font-semibold tracking-tight; }
|
||||
h2 { @apply text-base font-semibold; }
|
||||
p { @apply mt-1 text-sm text-zinc-400; }
|
||||
input, select, textarea { @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-zinc-100 outline-none focus:border-blue-500; }
|
||||
textarea { @apply min-h-24 font-mono text-xs; }
|
||||
button { @apply rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50; }
|
||||
table { @apply w-full border-collapse bg-zinc-900 text-left text-sm; }
|
||||
th { @apply bg-zinc-950 px-4 py-3 font-medium text-zinc-400; }
|
||||
td { @apply border-t border-zinc-800 px-4 py-3; }
|
||||
td small { @apply block text-zinc-500; }
|
||||
code { @apply font-mono text-xs text-blue-300; }
|
||||
button.danger { @apply bg-red-950 text-red-300 hover:bg-red-900; }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/console/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': 'http://127.0.0.1:3000' },
|
||||
},
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
# Cursor Protocol Debugger
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
This standalone local Cursor API debugging service forwards every HTTP request outside the `__debuger__` debugging namespace to the fixed upstream `https://api2.cursor.sh`, preserving the method, path, query, headers, and body. It continues to capture `BidiAppend`, `RunSSE`, Fork Chat, and model-discovery traffic.
|
||||
|
||||
It is not a general-purpose HTTP proxy, does not handle `CONNECT`, requires no CA certificate, and does not modify the system proxy.
|
||||
|
||||
## Start
|
||||
|
||||
Generate the sibling `cursor-proto` module's Go code before the first build:
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go run .
|
||||
```
|
||||
|
||||
The service listens on a single port:
|
||||
|
||||
- Cursor API service: `http://127.0.0.1:9090`
|
||||
- Debugging UI: `http://127.0.0.1:9090/__debuger__/`
|
||||
- Debugging API: `http://127.0.0.1:9090/__debuger__/api/*`
|
||||
- Fixed upstream: `https://api2.cursor.sh`
|
||||
|
||||
The debugging UI opens automatically after startup.
|
||||
|
||||
## Configure Cursor
|
||||
|
||||
Quit Cursor completely, then launch it from a terminal with the local API address:
|
||||
|
||||
```bash
|
||||
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
|
||||
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
|
||||
/Applications/Cursor.app/Contents/MacOS/Cursor
|
||||
```
|
||||
|
||||
`CURSOR_API_ENDPOINT` overrides the Agent API endpoint. `CURSOR_API_BASE_URL` also routes requests such as authentication that use the base API address through this service. Cursor proxy and Network settings do not need to be changed.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go build -o ./bin/cursor-proxy-debugger .
|
||||
```
|
||||
|
||||
## Dependency Layout
|
||||
|
||||
The debugger is an independent Go module. Cursor protobuf message packages are generated by the sibling `cursor-proto` module. The generated `gen/` directory is not committed, so run its `scripts/generate.sh` before the first build. This project does not depend on the outer `cursor-byok` Go module.
|
||||
|
||||
## Options
|
||||
|
||||
```text
|
||||
-addr Cursor API service listen address; default: 127.0.0.1:9090
|
||||
-max-exchanges Maximum exchanges retained in memory; default: 200
|
||||
-db SQLite database path; defaults to the user configuration directory
|
||||
-open Open the browser after startup; default: true
|
||||
```
|
||||
|
||||
## Data Handling
|
||||
|
||||
- Every request received by the service is forwarded to `https://api2.cursor.sh`; clients cannot select another upstream.
|
||||
- The `__debuger__` namespace is reserved for the local debugging page and API and is never forwarded upstream.
|
||||
- `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.
|
||||
- `CppService/AvailableModels`, `AiService/AvailableModels`, `GetDefaultModel`, and `GetDefaultModelNudgeData` are decoded bidirectionally.
|
||||
- Requests can be filtered by time and protocol `request_id`; the UI can query by `conversation_id` and group requests by conversation.
|
||||
- Complete captures are stored in SQLite and remain queryable after restart; `max-exchanges` only limits hot in-memory data.
|
||||
- Sensitive 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; capture limits never truncate forwarded traffic.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Cursor 协议调试器
|
||||
|
||||
[中文](README.md) | [English](README.en.md)
|
||||
|
||||
这是一个独立运行的本地 Cursor API 调试服务。除 `__debuger__` 调试命名空间外,进入服务端口的 HTTP 请求都会保留方法、路径、查询参数、请求头和请求体,并转发到固定上游 `https://api2.cursor.sh`。服务同时记录 `BidiAppend`、`RunSSE`、Fork Chat 和模型发现等流量。
|
||||
|
||||
它不是通用 HTTP 代理,不处理 `CONNECT`,不需要 CA 证书,也不会修改系统代理。
|
||||
|
||||
## 启动
|
||||
|
||||
首次构建前先生成相邻 `cursor-proto` 项目的 Go 代码:
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go run .
|
||||
```
|
||||
|
||||
服务只监听一个端口:
|
||||
|
||||
- Cursor API 服务:`http://127.0.0.1:9090`
|
||||
- 调试界面:`http://127.0.0.1:9090/__debuger__/`
|
||||
- 调试 API:`http://127.0.0.1:9090/__debuger__/api/*`
|
||||
- 固定上游:`https://api2.cursor.sh`
|
||||
|
||||
启动后会自动打开调试界面。
|
||||
|
||||
## 配置 Cursor
|
||||
|
||||
完全退出 Cursor 后,从终端指定本地 API 地址启动:
|
||||
|
||||
```bash
|
||||
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
|
||||
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
|
||||
/Applications/Cursor.app/Contents/MacOS/Cursor
|
||||
```
|
||||
|
||||
`CURSOR_API_ENDPOINT` 覆盖 Agent API 地址;`CURSOR_API_BASE_URL` 让使用基础 API 地址的认证等请求也经过本服务。无需修改 Cursor 代理设置或 Network 设置。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
(cd ../cursor-proto && ./scripts/generate.sh)
|
||||
go build -o ./bin/cursor-proxy-debugger .
|
||||
```
|
||||
|
||||
## 依赖说明
|
||||
|
||||
调试器是独立 Go module。Cursor protobuf 消息包由相邻的 `cursor-proto` module 生成;生成的 `gen/` 目录不提交到 Git,因此首次构建前需要运行其 `scripts/generate.sh`。本项目不依赖外层 `cursor-byok` Go module。
|
||||
|
||||
## 参数
|
||||
|
||||
```text
|
||||
-addr Cursor API 服务监听地址,默认 127.0.0.1:9090
|
||||
-max-exchanges 内存中保留的最大请求数,默认 200
|
||||
-db SQLite 数据库路径,默认位于用户配置目录
|
||||
-open 启动后是否打开浏览器,默认 true
|
||||
```
|
||||
|
||||
## 数据处理
|
||||
|
||||
- 所有服务端口收到的请求都固定转发到 `https://api2.cursor.sh`,不会接受客户端指定的其他上游。
|
||||
- `__debuger__` 命名空间由本地调试页面和调试 API 保留,不会转发到上游。
|
||||
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
|
||||
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`。
|
||||
- Fork Chat 的 `ForkBackgroundComposer`、`NotifyConversationClone` 和 `UploadConversationBlobs` 会双向解码为 protobuf JSON。
|
||||
- `CppService/AvailableModels`、`AiService/AvailableModels`、`GetDefaultModel` 和 `GetDefaultModelNudgeData` 会双向解码模型相关数据。
|
||||
- 请求列表支持按时间和协议 `request_id` 过滤;调试界面可按 `conversation_id` 查询并按会话分组。
|
||||
- 完整抓包写入 SQLite,重启后仍可查询;`max-exchanges` 只限制内存热数据数量。
|
||||
- `Authorization`、`Cookie`、`Set-Cookie` 等敏感请求头在界面中默认隐藏。
|
||||
- 单侧原始正文默认最多保留 2 MiB;转发内容不会被抓取上限截断。
|
||||
@@ -1,95 +0,0 @@
|
||||
// capture.go 在不影响上游转发的前提下截取有限大小的 HTTP 流。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// captureReadCloser 包装响应体并并发安全地累计诊断副本。
|
||||
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)
|
||||
}
|
||||
|
||||
// newCaptureReadCloser 创建带分块和完成回调的捕获读取器。
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// Read 转发读取结果并保存不超过限制的副本。
|
||||
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
|
||||
}
|
||||
|
||||
// Close 关闭原始响应体并保证完成回调只执行一次。
|
||||
func (reader *captureReadCloser) Close() error {
|
||||
err := reader.source.Close()
|
||||
reader.finish(err)
|
||||
return err
|
||||
}
|
||||
|
||||
// finish 固化捕获快照并在锁外调用完成回调。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// rawHex 把捕获字节编码为便于 JSON 持久化的十六进制文本。
|
||||
func rawHex(payload []byte) string {
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
// capture_pipeline.go 负责服务请求响应体的捕获、解码和事件追加。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// exchangeIDContextKey 隔离反向服务内部使用的捕获编号。
|
||||
type exchangeIDContextKey struct{}
|
||||
|
||||
// captureRequest 捕获请求元数据并安装请求体读取器。
|
||||
func (server *Server) captureRequest(request *http.Request) *http.Request {
|
||||
if request == nil {
|
||||
return request
|
||||
}
|
||||
server.captureMu.RLock()
|
||||
id := strconv.FormatUint(server.counter.Add(1), 10)
|
||||
path := request.URL.Path
|
||||
upstreamURL := *request.URL
|
||||
upstreamURL.Scheme = server.upstream.Scheme
|
||||
upstreamURL.Host = server.upstream.Host
|
||||
upstreamURL.User = nil
|
||||
requestContentType := request.Header.Get("Content-Type")
|
||||
requestCodec := requestContentCodec(path, request.Header)
|
||||
exchange := &Exchange{
|
||||
ExchangeSummary: ExchangeSummary{
|
||||
ID: id,
|
||||
StartedAt: time.Now(),
|
||||
Method: request.Method,
|
||||
URL: upstreamURL.String(),
|
||||
Host: server.upstream.Host,
|
||||
Path: path,
|
||||
State: "pending",
|
||||
},
|
||||
Request: Payload{
|
||||
Headers: sortedHeaders(request.Header),
|
||||
ContentType: requestContentType,
|
||||
ContentCodec: requestCodec,
|
||||
Frames: make([]FrameView, 0),
|
||||
},
|
||||
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
|
||||
}
|
||||
server.store.create(exchange)
|
||||
server.captureMu.RUnlock()
|
||||
request = request.WithContext(context.WithValue(request.Context(), exchangeIDContextKey{}, id))
|
||||
request.Close = false
|
||||
|
||||
if request.Body == nil {
|
||||
server.finishRequestBody(id, path, requestContentType, requestCodec, nil, 0, false, nil)
|
||||
return request
|
||||
}
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if messageType := streamingRequestMessageType(path); messageType != "" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
messageType,
|
||||
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, requestContentType, requestCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
// clearExchanges 清空内存和持久化捕获,并重置递增编号。
|
||||
func (server *Server) clearExchanges() error {
|
||||
server.captureMu.Lock()
|
||||
defer server.captureMu.Unlock()
|
||||
if err := server.store.clear(); err != nil {
|
||||
return err
|
||||
}
|
||||
server.counter.Store(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureResponse 创建响应记录更新并包装响应体捕获器。
|
||||
func (server *Server) captureResponse(response *http.Response) error {
|
||||
if response == nil {
|
||||
return nil
|
||||
}
|
||||
id := exchangeID(response.Request)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
path := ""
|
||||
if response.Request != nil && response.Request.URL != nil {
|
||||
path = response.Request.URL.Path
|
||||
}
|
||||
responseCodec := responseContentCodec(path, response.Header)
|
||||
responseContentType := response.Header.Get("Content-Type")
|
||||
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 = responseContentType
|
||||
exchange.Response.ContentCodec = responseCodec
|
||||
})
|
||||
if response.Body == nil {
|
||||
server.finishResponseBody(id, path, responseContentType, responseCodec, nil, 0, false, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
var frameDecoder *connectFrameDecoder
|
||||
if messageType := streamingResponseMessageType(path); messageType != "" {
|
||||
frameDecoder = newConnectFrameDecoder(
|
||||
messageType,
|
||||
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, responseContentType, responseCodec, captured, size, truncated, readErr)
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// failExchange 保存反向转发失败状态。
|
||||
func (server *Server) failExchange(request *http.Request, upstreamErr error) {
|
||||
id := exchangeID(request)
|
||||
if id == "" || upstreamErr == nil {
|
||||
return
|
||||
}
|
||||
server.store.update(id, func(exchange *Exchange) {
|
||||
exchange.State = "error"
|
||||
exchange.Error = upstreamErr.Error()
|
||||
exchange.DurationMS = elapsedMS(exchange.StartedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// finishRequestBody 解压、解码并保存完整请求体的最终状态。
|
||||
func (server *Server) finishRequestBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
decodeProto := decodesUnaryRequest(path) && isUnaryProtoContentType(contentType)
|
||||
if decodeProto && truncated {
|
||||
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
|
||||
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, decodedLang, kind, requestID, conversationID, decodeErr := "", "", "", "", "", contentDecodeErr
|
||||
if decodeProto && decodeErr == nil {
|
||||
decodedJSON, kind, requestID, conversationID, decodeErr = decodeUnaryRequest(path, decodePayload)
|
||||
}
|
||||
if decodeErr == nil && decodedJSON != "" {
|
||||
decodedLang = "json"
|
||||
} else if !decodeProto {
|
||||
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
|
||||
}
|
||||
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
|
||||
exchange.Request.DecodedLang = decodedLang
|
||||
}
|
||||
if kind != "" {
|
||||
exchange.RequestKind = kind
|
||||
}
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if conversationID != "" {
|
||||
exchange.ConversationID = conversationID
|
||||
}
|
||||
if decodeErr != nil {
|
||||
exchange.Request.DecodeError = decodeErr.Error()
|
||||
}
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
exchange.Error = readErr.Error()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// requestContentCodec 读取请求方向的 Connect 或 HTTP 压缩编码。
|
||||
func requestContentCodec(path string, headers http.Header) string {
|
||||
if streamingRequestMessageType(path) != "" {
|
||||
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
|
||||
}
|
||||
return strings.TrimSpace(headers.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
// responseContentCodec 读取响应方向的 Connect 或 HTTP 压缩编码。
|
||||
func responseContentCodec(path string, headers http.Header) string {
|
||||
if streamingResponseMessageType(path) != "" {
|
||||
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"))
|
||||
}
|
||||
|
||||
// finishResponseBody 解压、解码并保存完整响应体的最终状态。
|
||||
func (server *Server) finishResponseBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
|
||||
decodePayload := captured
|
||||
var contentDecodeErr error
|
||||
decodeProto := decodesUnaryResponse(path) && isUnaryProtoContentType(contentType)
|
||||
if decodeProto && truncated {
|
||||
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
|
||||
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
|
||||
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
|
||||
}
|
||||
decodedJSON, decodedLang, kind, decodeErr := "", "", "", contentDecodeErr
|
||||
if decodeProto && decodeErr == nil {
|
||||
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
|
||||
}
|
||||
if decodeErr == nil && decodedJSON != "" {
|
||||
decodedLang = "json"
|
||||
} else if !decodeProto {
|
||||
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
|
||||
}
|
||||
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
|
||||
exchange.Response.DecodedLang = decodedLang
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// appendRequestFrame 把请求方向的流式帧追加到临时快照。
|
||||
func (server *Server) appendRequestFrame(id string, frame FrameView) {
|
||||
server.store.updateTransient(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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// appendResponseFrame 把响应方向的流式帧追加到临时快照。
|
||||
func (server *Server) appendResponseFrame(id string, frame FrameView) {
|
||||
server.store.updateTransient(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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// exchangeID 从请求上下文读取捕获记录编号。
|
||||
func exchangeID(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := request.Context().Value(exchangeIDContextKey{}).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// browserAddress 把通配监听地址转换为浏览器可访问的回环地址。
|
||||
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)
|
||||
}
|
||||
|
||||
// validateLoopbackAddress 拒绝把调试服务暴露到非回环网卡。
|
||||
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
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// decode.go 解析 Connect 帧、压缩载荷和 Cursor protobuf 消息视图。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
|
||||
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// maxConnectFrameBytes 防止异常帧长度导致调试器分配过大内存。
|
||||
const maxConnectFrameBytes = 64 << 20
|
||||
|
||||
// 协议路径常量用于选择精确的 protobuf 请求、响应和流式消息类型。
|
||||
const (
|
||||
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
|
||||
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
|
||||
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
|
||||
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
|
||||
cppAvailableModelsPath = "/aiserver.v1.CppService/AvailableModels"
|
||||
aiAvailableModelsPath = "/aiserver.v1.AiService/AvailableModels"
|
||||
aiGetDefaultModelPath = "/aiserver.v1.AiService/GetDefaultModel"
|
||||
aiDefaultModelNudgeDataPath = "/aiserver.v1.AiService/GetDefaultModelNudgeData"
|
||||
mcpGetKnownServersPath = "/aiserver.v1.MCPRegistryService/GetKnownServers"
|
||||
serverGetConfigPath = "/aiserver.v1.ServerConfigService/GetServerConfig"
|
||||
runSSEPath = "/agent.v1.AgentService/RunSSE"
|
||||
)
|
||||
|
||||
// connectFrameDecoder 在任意读取边界下累计并解析 Connect 五字节帧。
|
||||
type connectFrameDecoder struct {
|
||||
buffer []byte
|
||||
messageType string
|
||||
codec string
|
||||
maxFrames int
|
||||
frameCount int
|
||||
onFrame func(FrameView)
|
||||
}
|
||||
|
||||
// newConnectFrameDecoder 创建指定 protobuf 类型的流式解码器。
|
||||
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
|
||||
return &connectFrameDecoder{
|
||||
messageType: messageType,
|
||||
codec: strings.TrimSpace(codec),
|
||||
maxFrames: maxFrames,
|
||||
onFrame: onFrame,
|
||||
}
|
||||
}
|
||||
|
||||
// Write 追加任意长度的网络片段并尽可能产出完整帧。
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// Close 标记流结束并暴露尚未完整的尾部错误。
|
||||
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
|
||||
}
|
||||
|
||||
// emit 在达到帧数上限前调用帧回调。
|
||||
func (decoder *connectFrameDecoder) emit(frame FrameView) {
|
||||
frame.Index = decoder.frameCount
|
||||
decoder.frameCount++
|
||||
if decoder.onFrame != nil {
|
||||
decoder.onFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
// decode 解压并解析单条 Connect 帧。
|
||||
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
|
||||
}
|
||||
|
||||
// decompressPayload 使用协议声明的编码解压载荷。
|
||||
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
|
||||
}
|
||||
|
||||
// decodeUnaryRequest 解析单次 RPC 请求并提取关键关联标识。
|
||||
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, conversationID 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 := decodeBidiClientMessage(request)
|
||||
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, conversationIDFromClientMessage(clientMessage), marshalErr
|
||||
}
|
||||
message, kind := unaryRequestMessage(path)
|
||||
if message == nil {
|
||||
return "", "", "", "", nil
|
||||
}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
return marshalProtoJSON(message), kind, "", conversationIDFromUnaryRequest(message), nil
|
||||
}
|
||||
|
||||
// decodeBidiClientMessage 解析 BidiAppend 携带的十六进制 Agent 消息。
|
||||
func decodeBidiClientMessage(request *aiserverv1.BidiAppendRequest) (*agentv1.AgentClientMessage, string, error) {
|
||||
if request == nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
if strings.TrimSpace(request.GetData()) != "" {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(request.GetData()))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode hex agent client message failed: %w", err)
|
||||
}
|
||||
message := &agentv1.AgentClientMessage{}
|
||||
if err := proto.Unmarshal(payload, message); err != nil {
|
||||
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
|
||||
}
|
||||
return message, activeOneofName(message), nil
|
||||
}
|
||||
if len(request.GetDataBinary()) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
message := &agentv1.AgentClientMessage{}
|
||||
if err := proto.Unmarshal(request.GetDataBinary(), message); err != nil {
|
||||
return nil, "", fmt.Errorf("decode binary agent client message failed: %w", err)
|
||||
}
|
||||
return message, activeOneofName(message), nil
|
||||
}
|
||||
|
||||
// conversationIDFromClientMessage 从 Agent 消息的会话字段提取会话标识。
|
||||
func conversationIDFromClientMessage(message *agentv1.AgentClientMessage) string {
|
||||
if message == nil {
|
||||
return ""
|
||||
}
|
||||
if runRequest := message.GetRunRequest(); runRequest != nil {
|
||||
return strings.TrimSpace(runRequest.GetConversationId())
|
||||
}
|
||||
if prewarmRequest := message.GetPrewarmRequest(); prewarmRequest != nil {
|
||||
return strings.TrimSpace(prewarmRequest.GetConversationId())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// conversationIDFromUnaryRequest 从已知 RPC 请求中提取会话标识。
|
||||
func conversationIDFromUnaryRequest(message proto.Message) string {
|
||||
switch typed := message.(type) {
|
||||
case *agentv1.NotifyConversationCloneRequest:
|
||||
return strings.TrimSpace(typed.GetConversationId())
|
||||
case *agentv1.UploadConversationBlobsRequest:
|
||||
return strings.TrimSpace(typed.GetConversationId())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// decodeUnaryResponse 解析单次 RPC 响应并生成 JSON 视图。
|
||||
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
|
||||
}
|
||||
|
||||
// hydrateStoredExchange 为历史捕获补齐正文和 Connect 帧视图。
|
||||
func hydrateStoredExchange(exchange *Exchange) bool {
|
||||
if exchange == nil || (exchange.State != "completed" && exchange.State != "streaming") {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if messageType := streamingRequestMessageType(exchange.Path); messageType != "" &&
|
||||
len(exchange.Request.Frames) == 0 && exchange.Request.RawHex != "" && !exchange.Request.RawTruncated {
|
||||
frames, err := decodeStoredConnectFrames(exchange.Request.RawHex, messageType, exchange.Request.ContentCodec)
|
||||
if err != nil {
|
||||
exchange.Request.DecodeError = err.Error()
|
||||
} else if len(frames) > 0 {
|
||||
exchange.Request.Frames = frames
|
||||
for _, frame := range frames {
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.RequestKind = frame.Kind
|
||||
}
|
||||
if frame.RequestID != "" {
|
||||
exchange.RequestID = frame.RequestID
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if messageType := streamingResponseMessageType(exchange.Path); messageType != "" &&
|
||||
len(exchange.Response.Frames) == 0 && exchange.Response.RawHex != "" && !exchange.Response.RawTruncated {
|
||||
frames, err := decodeStoredConnectFrames(exchange.Response.RawHex, messageType, exchange.Response.ContentCodec)
|
||||
if err != nil {
|
||||
exchange.Response.DecodeError = err.Error()
|
||||
} else if len(frames) > 0 {
|
||||
exchange.Response.Frames = frames
|
||||
exchange.FrameCount = len(frames)
|
||||
for _, frame := range frames {
|
||||
if frame.Kind != "" && frame.Kind != "end_stream" {
|
||||
exchange.ResponseKind = frame.Kind
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if isUnaryProtoContentType(exchange.Request.ContentType) && exchange.Request.DecodedJSON == "" && !exchange.Request.RawTruncated {
|
||||
payload, err := decodeStoredRawPayload(exchange.Request.RawHex, exchange.Request.ContentCodec)
|
||||
if err == nil {
|
||||
decoded, kind, requestID, conversationID, decodeErr := decodeUnaryRequest(exchange.Path, payload)
|
||||
if decodeErr != nil {
|
||||
err = decodeErr
|
||||
} else if decoded != "" {
|
||||
exchange.Request.DecodedJSON = decoded
|
||||
exchange.Request.DecodedLang = "json"
|
||||
exchange.RequestKind = kind
|
||||
if requestID != "" {
|
||||
exchange.RequestID = requestID
|
||||
}
|
||||
if conversationID != "" {
|
||||
exchange.ConversationID = conversationID
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
exchange.Request.DecodeError = err.Error()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if isUnaryProtoContentType(exchange.Response.ContentType) && exchange.Response.DecodedJSON == "" && !exchange.Response.RawTruncated {
|
||||
payload, err := decodeStoredRawPayload(exchange.Response.RawHex, exchange.Response.ContentCodec)
|
||||
if err == nil {
|
||||
decoded, kind, decodeErr := decodeUnaryResponse(exchange.Path, payload)
|
||||
if decodeErr != nil {
|
||||
err = decodeErr
|
||||
} else if decoded != "" {
|
||||
exchange.Response.DecodedJSON = decoded
|
||||
exchange.Response.DecodedLang = "json"
|
||||
exchange.ResponseKind = kind
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
exchange.Response.DecodeError = err.Error()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if exchange.Request.DecodedJSON == "" && exchange.Request.RawHex != "" &&
|
||||
!isProtoContentType(exchange.Request.ContentType) && streamingRequestMessageType(exchange.Path) == "" {
|
||||
if hydrateStoredTextPayload(&exchange.Request) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if exchange.Response.DecodedJSON == "" && exchange.Response.RawHex != "" &&
|
||||
!isProtoContentType(exchange.Response.ContentType) && streamingResponseMessageType(exchange.Path) == "" {
|
||||
if hydrateStoredTextPayload(&exchange.Response) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hydrateStoredTextPayload 为历史文本载荷补齐 JSON 视图。
|
||||
func hydrateStoredTextPayload(payload *Payload) bool {
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(payload.RawHex))
|
||||
if err != nil {
|
||||
payload.DecodeError = fmt.Sprintf("解析已存储正文失败:%v", err)
|
||||
return true
|
||||
}
|
||||
decoded, language, decodeErr := decodeCapturedContent(raw, payload.ContentType, payload.ContentCodec)
|
||||
if decoded == "" && decodeErr == nil {
|
||||
return false
|
||||
}
|
||||
payload.DecodedJSON = decoded
|
||||
payload.DecodedLang = language
|
||||
if decodeErr != nil {
|
||||
payload.DecodeError = decodeErr.Error()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeCapturedContent 按媒体类型和压缩编码解码任意捕获正文。
|
||||
@@ -1,397 +0,0 @@
|
||||
// decode_stored.go 负责从持久化捕获记录恢复文本、帧和 protobuf 视图。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
|
||||
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/reflect/protoregistry"
|
||||
"google.golang.org/protobuf/types/dynamicpb"
|
||||
)
|
||||
|
||||
// decodeCapturedContent 按内容类型和压缩编码生成可读正文视图。
|
||||
func decodeCapturedContent(payload []byte, contentType, codec string) (string, string, error) {
|
||||
decoded, err := decodeHTTPContent(payload, codec)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
mediaType := normalizedMediaType(contentType)
|
||||
if json.Valid(decoded) {
|
||||
var formatted bytes.Buffer
|
||||
if err := json.Indent(&formatted, decoded, "", " "); err != nil {
|
||||
return string(decoded), "json", err
|
||||
}
|
||||
return formatted.String(), "json", nil
|
||||
}
|
||||
if mediaType == "application/x-www-form-urlencoded" && utf8.Valid(decoded) {
|
||||
values, parseErr := url.ParseQuery(string(decoded))
|
||||
if parseErr != nil {
|
||||
return string(decoded), "plaintext", parseErr
|
||||
}
|
||||
formatted, marshalErr := json.MarshalIndent(values, "", " ")
|
||||
return string(formatted), "json", marshalErr
|
||||
}
|
||||
if !isTextMediaType(mediaType) || !utf8.Valid(decoded) {
|
||||
return "", "", nil
|
||||
}
|
||||
if strings.ContainsRune(string(decoded), '\x00') {
|
||||
return "", "", nil
|
||||
}
|
||||
language := textLanguage(mediaType)
|
||||
if strings.HasSuffix(mediaType, "+json") || mediaType == "application/json" {
|
||||
return string(decoded), "json", fmt.Errorf("JSON 正文格式无效")
|
||||
}
|
||||
return string(decoded), language, nil
|
||||
}
|
||||
|
||||
// decodeHTTPContent 解压 HTTP 内容编码并返回正文副本。
|
||||
func decodeHTTPContent(payload []byte, codec string) ([]byte, error) {
|
||||
encodings := strings.Split(strings.TrimSpace(codec), ",")
|
||||
decoded := payload
|
||||
for index := len(encodings) - 1; index >= 0; index-- {
|
||||
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
|
||||
switch encoding {
|
||||
case "", "identity":
|
||||
case "gzip", "x-gzip":
|
||||
var err error
|
||||
decoded, err = decompressPayload(decoded, "gzip")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "deflate":
|
||||
reader, err := zlib.NewReader(bytes.NewReader(decoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deflate 解压失败:%w", err)
|
||||
}
|
||||
result, readErr := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("读取 deflate 内容失败:%w", readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("关闭 deflate 内容失败:%w", closeErr)
|
||||
}
|
||||
if len(result) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("deflate 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
decoded = result
|
||||
case "br":
|
||||
result, readErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(decoded)), maxConnectFrameBytes+1))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("读取 Brotli 内容失败:%w", readErr)
|
||||
}
|
||||
if len(result) > maxConnectFrameBytes {
|
||||
return nil, fmt.Errorf("Brotli 解压后超过 %d 字节限制", maxConnectFrameBytes)
|
||||
}
|
||||
decoded = result
|
||||
default:
|
||||
return nil, fmt.Errorf("暂不支持内容编码 %q", encoding)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// normalizedMediaType 删除参数并统一媒体类型大小写。
|
||||
func normalizedMediaType(contentType string) string {
|
||||
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
|
||||
if err == nil {
|
||||
return strings.ToLower(mediaType)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||
}
|
||||
|
||||
// isProtoContentType 判断媒体类型是否表示 protobuf 二进制。
|
||||
func isProtoContentType(contentType string) bool {
|
||||
return strings.Contains(normalizedMediaType(contentType), "proto")
|
||||
}
|
||||
|
||||
// isTextMediaType 判断媒体类型是否适合直接作为文本展示。
|
||||
func isTextMediaType(mediaType string) bool {
|
||||
return strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") ||
|
||||
strings.HasSuffix(mediaType, "+xml") || mediaType == "application/json" ||
|
||||
mediaType == "application/xml" || mediaType == "application/javascript" ||
|
||||
mediaType == "application/x-javascript" || mediaType == "application/graphql"
|
||||
}
|
||||
|
||||
// textLanguage 为前端编辑器选择文本语言。
|
||||
func textLanguage(mediaType string) string {
|
||||
switch {
|
||||
case strings.Contains(mediaType, "json"):
|
||||
return "json"
|
||||
case strings.Contains(mediaType, "xml"):
|
||||
return "xml"
|
||||
case strings.Contains(mediaType, "html"):
|
||||
return "html"
|
||||
case strings.Contains(mediaType, "javascript"):
|
||||
return "javascript"
|
||||
case strings.Contains(mediaType, "css"):
|
||||
return "css"
|
||||
default:
|
||||
return "plaintext"
|
||||
}
|
||||
}
|
||||
|
||||
// decodeStoredConnectFrames 从持久化十六进制载荷恢复流式帧。
|
||||
func decodeStoredConnectFrames(rawHexValue, messageType, codec string) ([]FrameView, error) {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析已存储 Connect 正文失败:%w", err)
|
||||
}
|
||||
frames := make([]FrameView, 0)
|
||||
decoder := newConnectFrameDecoder(messageType, codec, defaultMaxFrames, func(frame FrameView) {
|
||||
frames = append(frames, frame)
|
||||
})
|
||||
decoder.Write(payload)
|
||||
decoder.Close()
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
// isUnaryProtoContentType 判断媒体类型是否为可直接解码的 protobuf。
|
||||
func isUnaryProtoContentType(contentType string) bool {
|
||||
mediaType := normalizedMediaType(contentType)
|
||||
return mediaType == "application/proto" || mediaType == "application/protobuf" || mediaType == "application/x-protobuf"
|
||||
}
|
||||
|
||||
// decodeStoredRawPayload 解码持久化原始载荷并应用压缩处理。
|
||||
func decodeStoredRawPayload(rawHexValue, codec string) ([]byte, error) {
|
||||
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析已存储正文失败:%w", err)
|
||||
}
|
||||
if codec == "" || strings.EqualFold(codec, "identity") {
|
||||
return payload, nil
|
||||
}
|
||||
return decompressPayload(payload, codec)
|
||||
}
|
||||
|
||||
// unaryRequestMessage 根据 RPC 路径创建请求消息和稳定类型名。
|
||||
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"
|
||||
case cppAvailableModelsPath:
|
||||
return &aiserverv1.AvailableCppModelsRequest{}, "available_cpp_models_request"
|
||||
case aiAvailableModelsPath:
|
||||
return &aiserverv1.AvailableModelsRequest{}, "available_models_request"
|
||||
case aiGetDefaultModelPath:
|
||||
return &aiserverv1.GetDefaultModelRequest{}, "get_default_model_request"
|
||||
case aiDefaultModelNudgeDataPath:
|
||||
return &aiserverv1.GetDefaultModelNudgeDataRequest{}, "get_default_model_nudge_data_request"
|
||||
case mcpGetKnownServersPath:
|
||||
return &aiserverv1.GetKnownServersRequest{}, "get_known_servers_request"
|
||||
case serverGetConfigPath:
|
||||
return &aiserverv1.GetServerConfigRequest{}, "get_server_config_request"
|
||||
default:
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
|
||||
return nil, ""
|
||||
}
|
||||
return dynamicpb.NewMessage(method.Input()), protoMessageKind(method.Input())
|
||||
}
|
||||
}
|
||||
|
||||
// unaryResponseMessage 根据 RPC 路径创建响应消息和稳定类型名。
|
||||
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"
|
||||
case cppAvailableModelsPath:
|
||||
return &aiserverv1.AvailableCppModelsResponse{}, "available_cpp_models_response"
|
||||
case aiAvailableModelsPath:
|
||||
return &aiserverv1.AvailableModelsResponse{}, "available_models_response"
|
||||
case aiGetDefaultModelPath:
|
||||
return &aiserverv1.GetDefaultModelResponse{}, "get_default_model_response"
|
||||
case aiDefaultModelNudgeDataPath:
|
||||
return &aiserverv1.GetDefaultModelNudgeDataResponse{}, "get_default_model_nudge_data_response"
|
||||
case mcpGetKnownServersPath:
|
||||
return &aiserverv1.GetKnownServersResponse{}, "get_known_servers_response"
|
||||
case serverGetConfigPath:
|
||||
return &aiserverv1.GetServerConfigResponse{}, "get_server_config_response"
|
||||
default:
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
|
||||
return nil, ""
|
||||
}
|
||||
return dynamicpb.NewMessage(method.Output()), protoMessageKind(method.Output())
|
||||
}
|
||||
}
|
||||
|
||||
// streamingRequestMessageType 返回流式请求的 protobuf 类型名。
|
||||
func streamingRequestMessageType(path string) string {
|
||||
if path == runSSEPath {
|
||||
return "aiserver.v1.BidiRequestId"
|
||||
}
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
|
||||
return ""
|
||||
}
|
||||
return string(method.Input().FullName())
|
||||
}
|
||||
|
||||
// streamingResponseMessageType 返回流式响应的 protobuf 类型名。
|
||||
func streamingResponseMessageType(path string) string {
|
||||
if path == runSSEPath {
|
||||
return "agent.v1.AgentServerMessage"
|
||||
}
|
||||
method := rpcMethodDescriptor(path)
|
||||
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
|
||||
return ""
|
||||
}
|
||||
return string(method.Output().FullName())
|
||||
}
|
||||
|
||||
// decodesUnaryRequest 判断是否存在已知的一元请求解码器。
|
||||
func decodesUnaryRequest(path string) bool {
|
||||
if path == bidiAppendPath {
|
||||
return true
|
||||
}
|
||||
message, _ := unaryRequestMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
// decodesUnaryResponse 判断是否存在已知的一元响应解码器。
|
||||
func decodesUnaryResponse(path string) bool {
|
||||
message, _ := unaryResponseMessage(path)
|
||||
return message != nil
|
||||
}
|
||||
|
||||
// newMessage 按完整 protobuf 类型名从注册表创建消息实例。
|
||||
func newMessage(messageType string) proto.Message {
|
||||
switch messageType {
|
||||
case "aiserver.v1.BidiRequestId":
|
||||
return &aiserverv1.BidiRequestId{}
|
||||
case "agent.v1.AgentServerMessage":
|
||||
return &agentv1.AgentServerMessage{}
|
||||
default:
|
||||
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(messageType))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return dynamicpb.NewMessage(messageDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// rpcMethodDescriptor 通过完整 RPC 路径查找注册表中的方法描述。
|
||||
func rpcMethodDescriptor(path string) protoreflect.MethodDescriptor {
|
||||
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil
|
||||
}
|
||||
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(parts[0]))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
service, ok := descriptor.(protoreflect.ServiceDescriptor)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return service.Methods().ByName(protoreflect.Name(parts[1]))
|
||||
}
|
||||
|
||||
// protoMessageKind 从消息描述推导稳定的 JSON kind 名称。
|
||||
func protoMessageKind(descriptor protoreflect.MessageDescriptor) string {
|
||||
if descriptor == nil {
|
||||
return ""
|
||||
}
|
||||
return snakeCase(string(descriptor.Name()))
|
||||
}
|
||||
|
||||
// snakeCase 将 protobuf 名称转换为前端稳定的下划线命名。
|
||||
func snakeCase(value string) string {
|
||||
var result strings.Builder
|
||||
for index, character := range value {
|
||||
if unicode.IsUpper(character) {
|
||||
if index > 0 {
|
||||
result.WriteByte('_')
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(character))
|
||||
continue
|
||||
}
|
||||
result.WriteRune(character)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// marshalProtoJSON 把 protobuf 消息编码为前端可读 JSON。
|
||||
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)
|
||||
}
|
||||
|
||||
// activeOneofName 返回 Agent 消息当前激活的 oneof 名称。
|
||||
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())
|
||||
}
|
||||
|
||||
// prettyJSON 尝试格式化 JSON,失败时返回原始文本。
|
||||
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)
|
||||
}
|
||||
|
||||
// clippedHex 限制原始载荷展示长度并标记省略部分。
|
||||
func clippedHex(payload []byte, max int) string {
|
||||
if len(payload) > max {
|
||||
return hex.EncodeToString(payload[:max]) + "..."
|
||||
}
|
||||
return hex.EncodeToString(payload)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
module github.com/leookun/cursor-proxy-debugger
|
||||
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/leookun/cursor-byok/cursor-proto v0.0.0
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
google.golang.org/protobuf v1.36.11
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/leookun/cursor-byok/cursor-proto => ../cursor-proto
|
||||
@@ -1,62 +0,0 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,50 +0,0 @@
|
||||
// cursor-proxy-debugger 提供独立 Cursor API 调试服务的进程入口。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
// main 解析启动参数,并管理调试服务的完整生命周期。
|
||||
func main() {
|
||||
config := Config{}
|
||||
openBrowser := true
|
||||
flag.StringVar(&config.ServiceAddr, "addr", defaultServiceAddr, "Cursor API 调试服务监听地址")
|
||||
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
|
||||
flag.StringVar(&config.DatabasePath, "db", "", "SQLite 数据库路径(默认使用用户配置目录)")
|
||||
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
|
||||
flag.Parse()
|
||||
|
||||
server, err := New(config)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Cursor API 调试服务已启动\n")
|
||||
fmt.Printf("服务地址: http://%s\n", server.ServiceAddr())
|
||||
fmt.Printf("固定上游: %s\n", defaultUpstreamURL)
|
||||
fmt.Printf("调试界面: %s\n", server.UIURL())
|
||||
fmt.Printf("SQLite: %s\n", server.DatabasePath())
|
||||
if openBrowser {
|
||||
_ = browser.OpenURL(server.UIURL())
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-signals
|
||||
signal.Stop(signals)
|
||||
|
||||
if err := server.Close(); err != nil {
|
||||
log.Printf("关闭调试服务失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// server.go 负责固定上游服务、流量捕获和调试界面的生命周期。
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server 运行 Cursor API 转发服务及其本机调试界面。
|
||||
type Server struct {
|
||||
config Config
|
||||
upstream *url.URL
|
||||
store *exchangeStore
|
||||
counter atomic.Uint64
|
||||
serviceServer *http.Server
|
||||
serviceLn net.Listener
|
||||
runMu sync.Mutex
|
||||
captureMu sync.RWMutex
|
||||
}
|
||||
|
||||
// New 创建固定转发到 Cursor API 的协议调试服务。
|
||||
func New(config Config) (*Server, error) {
|
||||
config = config.normalized()
|
||||
if err := validateLoopbackAddress(config.ServiceAddr); err != nil {
|
||||
return nil, fmt.Errorf("服务监听地址无效:%w", err)
|
||||
}
|
||||
upstream, err := url.Parse(defaultUpstreamURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析固定上游地址:%w", err)
|
||||
}
|
||||
store, err := newPersistentExchangeStore(config.DatabasePath, config.MaxExchanges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server := &Server{
|
||||
config: config,
|
||||
upstream: upstream,
|
||||
store: store,
|
||||
}
|
||||
server.counter.Store(store.maxNumericID())
|
||||
server.serviceServer = &http.Server{
|
||||
Handler: server.newServiceHandler(),
|
||||
ErrorLog: log.New(io.Discard, "", 0),
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Start 启动同时承载 API 转发和调试界面的单端口服务。
|
||||
func (server *Server) Start() error {
|
||||
server.runMu.Lock()
|
||||
defer server.runMu.Unlock()
|
||||
if server.serviceLn != nil {
|
||||
return errors.New("Cursor API 调试服务已经启动")
|
||||
}
|
||||
serviceListener, err := net.Listen("tcp", server.config.ServiceAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("启动 API 服务监听失败:%w", err)
|
||||
}
|
||||
server.serviceLn = serviceListener
|
||||
go func() { _ = server.serviceServer.Serve(serviceListener) }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 立即关闭监听器、活跃连接并释放捕获存储。
|
||||
func (server *Server) Close() error {
|
||||
server.runMu.Lock()
|
||||
serviceServer := server.serviceServer
|
||||
server.serviceLn = nil
|
||||
server.runMu.Unlock()
|
||||
var errorsList []error
|
||||
if serviceServer != nil {
|
||||
if err := serviceServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
if server.store != nil {
|
||||
if err := server.store.close(); err != nil {
|
||||
errorsList = append(errorsList, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errorsList...)
|
||||
}
|
||||
|
||||
// ServiceAddr 返回 Cursor API 服务监听地址。
|
||||
func (server *Server) ServiceAddr() string { return server.config.ServiceAddr }
|
||||
|
||||
// UIURL 返回可在浏览器中打开的调试界面地址。
|
||||
func (server *Server) UIURL() string {
|
||||
return "http://" + browserAddress(server.config.ServiceAddr) + debugBasePath + "/"
|
||||
}
|
||||
|
||||
// DatabasePath 返回捕获数据库路径。
|
||||
func (server *Server) DatabasePath() string {
|
||||
return server.config.DatabasePath
|
||||
}
|
||||
|
||||
// newServiceHandler 创建单端口调试路由和固定上游流式转发。
|
||||
func (server *Server) newServiceHandler() http.Handler {
|
||||
reverseProxy := httputil.NewSingleHostReverseProxy(server.upstream)
|
||||
reverseProxy.FlushInterval = -1
|
||||
reverseProxy.ErrorLog = log.New(io.Discard, "", 0)
|
||||
originalDirector := reverseProxy.Director
|
||||
reverseProxy.Director = func(request *http.Request) {
|
||||
originalDirector(request)
|
||||
request.Host = server.upstream.Host
|
||||
request.Header["X-Forwarded-For"] = nil
|
||||
}
|
||||
reverseProxy.Transport = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
DisableCompression: true,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
reverseProxy.ModifyResponse = server.captureResponse
|
||||
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, upstreamErr error) {
|
||||
server.failExchange(request, upstreamErr)
|
||||
http.Error(writer, "Cursor API upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
forwardHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
reverseProxy.ServeHTTP(writer, server.captureRequest(request))
|
||||
})
|
||||
debugHandler := http.StripPrefix(debugBasePath, server.newUIHandler())
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(debugBasePath+"/", debugHandler)
|
||||
mux.HandleFunc(debugBasePath, func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(writer, request, debugBasePath+"/", http.StatusTemporaryRedirect)
|
||||
})
|
||||
mux.Handle("/", forwardHandler)
|
||||
return mux
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// store.go 管理调试捕获的内存索引、SQLite 持久化和订阅通知。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// exchangeStore 保存有限内存窗口以及可选的持久化数据库连接。
|
||||
type exchangeStore struct {
|
||||
mu sync.RWMutex
|
||||
max int
|
||||
order []string
|
||||
exchanges map[string]*Exchange
|
||||
subscribers map[chan storeEvent]struct{}
|
||||
db *sql.DB
|
||||
databasePath string
|
||||
lastError string
|
||||
}
|
||||
|
||||
// newExchangeStore 创建仅使用内存的捕获存储。
|
||||
func newExchangeStore(max int) *exchangeStore {
|
||||
return &exchangeStore{
|
||||
max: max,
|
||||
exchanges: make(map[string]*Exchange),
|
||||
subscribers: make(map[chan storeEvent]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// newPersistentExchangeStore 创建 SQLite 持久化捕获存储并恢复最近记录。
|
||||
func newPersistentExchangeStore(path string, max int) (*exchangeStore, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("SQLite 数据库路径不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开 SQLite 数据库失败: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
store := newExchangeStore(max)
|
||||
store.db = db
|
||||
store.databasePath = path
|
||||
if err := store.initializeDatabase(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.backfillDecodedExchanges(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadRecent(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// backfillDecodedExchanges 为旧记录补齐解码视图并写回数据库。
|
||||
func (store *exchangeStore) backfillDecodedExchanges(ctx context.Context) error {
|
||||
rows, err := store.db.QueryContext(ctx, "SELECT payload_json FROM exchanges")
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取待回填的 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
var exchanges []Exchange
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("解析待回填的 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
if hydrateStoredExchange(&exchange) {
|
||||
exchanges = append(exchanges, exchange)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(exchanges) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
for index := range exchanges {
|
||||
payload, marshalErr := json.Marshal(&exchanges[index])
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE exchanges SET payload_json = ?, conversation_id = ?,
|
||||
request_id = ?, updated_at_ms = ? WHERE id = ?`, payload, exchanges[index].ConversationID,
|
||||
exchanges[index].RequestID, time.Now().UnixMilli(), exchanges[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeDatabase 创建调试器使用的 SQLite 表结构。
|
||||
func (store *exchangeStore) initializeDatabase(ctx context.Context) error {
|
||||
for _, statement := range []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
"PRAGMA secure_delete = ON",
|
||||
`CREATE TABLE IF NOT EXISTS exchanges (
|
||||
id TEXT PRIMARY KEY,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
request_id TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
request_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json BLOB NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS exchanges_conversation_started_idx ON exchanges(conversation_id, started_at_ms DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS exchanges_request_idx ON exchanges(request_id)",
|
||||
} {
|
||||
if _, err := store.db.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("初始化 SQLite 数据库失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRecent 从数据库恢复内存窗口中的最新捕获。
|
||||
func (store *exchangeStore) loadRecent(ctx context.Context) error {
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT payload_json, conversation_id
|
||||
FROM exchanges ORDER BY started_at_ms DESC, id DESC LIMIT ?`, store.max)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
var conversationID string
|
||||
if err := rows.Scan(&payload, &conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
return fmt.Errorf("解析 SQLite 抓包记录失败: %w", err)
|
||||
}
|
||||
exchange.ConversationID = conversationID
|
||||
store.exchanges[exchange.ID] = &exchange
|
||||
store.order = append(store.order, exchange.ID)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// create 添加一条新的捕获并通知订阅者。
|
||||
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.persistLocked(exchange)
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "created", ID: exchange.ID})
|
||||
}
|
||||
|
||||
// update 持久化修改并发布最终捕获快照。
|
||||
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
|
||||
store.updateWithPersistence(id, apply, true)
|
||||
}
|
||||
|
||||
// updateTransient 只更新内存并发布流式过程快照。
|
||||
func (store *exchangeStore) updateTransient(id string, apply func(*Exchange)) {
|
||||
store.updateWithPersistence(id, apply, false)
|
||||
}
|
||||
|
||||
// updateWithPersistence 在统一锁内完成修改、关联和可选持久化。
|
||||
func (store *exchangeStore) updateWithPersistence(id string, apply func(*Exchange), persist bool) {
|
||||
store.mu.Lock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange == nil && store.db != nil {
|
||||
var err error
|
||||
exchange, err = store.loadPersistedLocked(id)
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
if exchange != nil {
|
||||
store.exchanges[id] = exchange
|
||||
store.order = append([]string{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if exchange != nil {
|
||||
previousRequestID := exchange.RequestID
|
||||
previousConversationID := exchange.ConversationID
|
||||
apply(exchange)
|
||||
if exchange.RequestID != previousRequestID || exchange.ConversationID != previousConversationID {
|
||||
store.associateConversationLocked(exchange)
|
||||
}
|
||||
if persist {
|
||||
store.persistLocked(exchange)
|
||||
}
|
||||
}
|
||||
store.mu.Unlock()
|
||||
store.publish(storeEvent{Type: "updated", ID: id})
|
||||
}
|
||||
|
||||
// summaries 返回按时间倒序排列的请求摘要。
|
||||
@@ -1,354 +0,0 @@
|
||||
// store_queries.go 负责调试捕获记录的查询、持久化辅助和订阅通知。
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// summaries 返回指定会话的捕获摘要列表。
|
||||
func (store *exchangeStore) summaries(conversationID string) ([]ExchangeSummary, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
if store.db != nil {
|
||||
query := `SELECT payload_json, conversation_id FROM exchanges`
|
||||
arguments := []any{}
|
||||
if conversationID != "" {
|
||||
query += " WHERE conversation_id = ?"
|
||||
arguments = append(arguments, conversationID)
|
||||
}
|
||||
query += " ORDER BY started_at_ms DESC, id DESC"
|
||||
rows, err := store.db.Query(query, arguments...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ExchangeSummary, 0)
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
var persistedConversationID string
|
||||
if err := rows.Scan(&payload, &persistedConversationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var exchange Exchange
|
||||
if err := json.Unmarshal(payload, &exchange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exchange.ConversationID = persistedConversationID
|
||||
if current := store.exchanges[exchange.ID]; current != nil {
|
||||
result = append(result, current.ExchangeSummary)
|
||||
} else {
|
||||
result = append(result, exchange.ExchangeSummary)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
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, nil
|
||||
}
|
||||
|
||||
// get 返回内存或数据库中的完整捕获副本。
|
||||
func (store *exchangeStore) get(id string) (Exchange, bool, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
exchange := store.exchanges[id]
|
||||
if exchange != nil {
|
||||
return cloneExchange(*exchange), true, nil
|
||||
}
|
||||
if store.db == nil {
|
||||
return Exchange{}, false, nil
|
||||
}
|
||||
persisted, err := store.loadPersistedLocked(id)
|
||||
if err != nil {
|
||||
return Exchange{}, false, err
|
||||
}
|
||||
if persisted == nil {
|
||||
return Exchange{}, false, nil
|
||||
}
|
||||
return *persisted, true, nil
|
||||
}
|
||||
|
||||
// loadPersistedLocked 从 SQLite 读取单条捕获并在必要时解码回填。
|
||||
func (store *exchangeStore) loadPersistedLocked(id string) (*Exchange, error) {
|
||||
var payload []byte
|
||||
var conversationID string
|
||||
err := store.db.QueryRow("SELECT payload_json, conversation_id FROM exchanges WHERE id = ?", id).Scan(&payload, &conversationID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var persisted Exchange
|
||||
if err := json.Unmarshal(payload, &persisted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
persisted.ConversationID = conversationID
|
||||
return &persisted, nil
|
||||
}
|
||||
|
||||
// clear 清除数据库、内存索引和会话关联。
|
||||
func (store *exchangeStore) clear() error {
|
||||
store.mu.Lock()
|
||||
var err error
|
||||
if store.db != nil {
|
||||
_, err = store.db.Exec("DELETE FROM exchanges")
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
store.order = nil
|
||||
store.exchanges = make(map[string]*Exchange)
|
||||
store.lastError = ""
|
||||
}
|
||||
store.mu.Unlock()
|
||||
if err == nil {
|
||||
store.publish(storeEvent{Type: "cleared"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// conversations 按会话聚合持久化捕获统计。
|
||||
func (store *exchangeStore) conversations() ([]ConversationSummary, error) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
if store.db == nil {
|
||||
groups := make(map[string]*ConversationSummary)
|
||||
for _, exchange := range store.exchanges {
|
||||
group := groups[exchange.ConversationID]
|
||||
if group == nil {
|
||||
group = &ConversationSummary{ConversationID: exchange.ConversationID}
|
||||
groups[exchange.ConversationID] = group
|
||||
}
|
||||
group.ExchangeCount++
|
||||
group.RequestBytes += exchange.RequestBytes
|
||||
group.ResponseBytes += exchange.ResponseBytes
|
||||
if exchange.StartedAt.After(group.LastStartedAt) {
|
||||
group.LastStartedAt = exchange.StartedAt
|
||||
}
|
||||
}
|
||||
result := make([]ConversationSummary, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
result = append(result, *group)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].LastStartedAt.After(result[j].LastStartedAt) })
|
||||
return result, nil
|
||||
}
|
||||
rows, err := store.db.Query(`SELECT conversation_id, COUNT(*), MAX(started_at_ms),
|
||||
COALESCE(SUM(request_bytes), 0), COALESCE(SUM(response_bytes), 0)
|
||||
FROM exchanges GROUP BY conversation_id ORDER BY MAX(started_at_ms) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ConversationSummary, 0)
|
||||
for rows.Next() {
|
||||
var summary ConversationSummary
|
||||
var startedAtMS int64
|
||||
if err := rows.Scan(&summary.ConversationID, &summary.ExchangeCount, &startedAtMS, &summary.RequestBytes, &summary.ResponseBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.LastStartedAt = time.UnixMilli(startedAtMS)
|
||||
result = append(result, summary)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// persistLocked 将当前捕获快照写入 SQLite。
|
||||
func (store *exchangeStore) persistLocked(exchange *Exchange) {
|
||||
if store.db == nil || exchange == nil {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(exchange)
|
||||
if err == nil {
|
||||
_, err = store.db.Exec(`INSERT INTO exchanges (
|
||||
id, started_at_ms, conversation_id, request_id, state, request_bytes,
|
||||
response_bytes, payload_json, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
started_at_ms = excluded.started_at_ms,
|
||||
conversation_id = excluded.conversation_id,
|
||||
request_id = excluded.request_id,
|
||||
state = excluded.state,
|
||||
request_bytes = excluded.request_bytes,
|
||||
response_bytes = excluded.response_bytes,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at_ms = excluded.updated_at_ms`,
|
||||
exchange.ID, exchange.StartedAt.UnixMilli(), exchange.ConversationID,
|
||||
exchange.RequestID, exchange.State, exchange.RequestBytes,
|
||||
exchange.ResponseBytes, payload, time.Now().UnixMilli())
|
||||
}
|
||||
if err != nil {
|
||||
store.lastError = err.Error()
|
||||
} else {
|
||||
store.lastError = ""
|
||||
}
|
||||
}
|
||||
|
||||
// associateConversationLocked 根据请求标识补齐会话关联。
|
||||
func (store *exchangeStore) associateConversationLocked(exchange *Exchange) {
|
||||
if exchange.RequestID == "" {
|
||||
return
|
||||
}
|
||||
if exchange.ConversationID == "" {
|
||||
for _, candidate := range store.exchanges {
|
||||
if candidate.RequestID == exchange.RequestID && candidate.ConversationID != "" {
|
||||
exchange.ConversationID = candidate.ConversationID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if exchange.ConversationID == "" && store.db != nil {
|
||||
_ = store.db.QueryRow(`SELECT conversation_id FROM exchanges
|
||||
WHERE request_id = ? AND conversation_id != ''
|
||||
ORDER BY started_at_ms DESC LIMIT 1`, exchange.RequestID).Scan(&exchange.ConversationID)
|
||||
}
|
||||
if exchange.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
for _, candidate := range store.exchanges {
|
||||
if candidate.RequestID == exchange.RequestID && candidate.ConversationID == "" {
|
||||
candidate.ConversationID = exchange.ConversationID
|
||||
store.persistLocked(candidate)
|
||||
}
|
||||
}
|
||||
if store.db != nil {
|
||||
if _, err := store.db.Exec(`UPDATE exchanges SET conversation_id = ?, updated_at_ms = ?
|
||||
WHERE request_id = ? AND conversation_id = ''`, exchange.ConversationID, time.Now().UnixMilli(), exchange.RequestID); err != nil {
|
||||
store.lastError = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maxNumericID 返回数据库中已使用的最大数字捕获编号。
|
||||
func (store *exchangeStore) maxNumericID() uint64 {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
var maximum uint64
|
||||
if store.db != nil {
|
||||
_ = store.db.QueryRow("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) FROM exchanges").Scan(&maximum)
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
// close 关闭数据库连接并终止后续订阅通知。
|
||||
func (store *exchangeStore) close() error {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
if store.db == nil {
|
||||
return nil
|
||||
}
|
||||
err := store.db.Close()
|
||||
store.db = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// status 返回数据库路径和最近一次数据库错误。
|
||||
func (store *exchangeStore) status() (string, string) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
return store.databasePath, store.lastError
|
||||
}
|
||||
|
||||
// subscribe 注册一个捕获变化订阅者。
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// publish 非阻塞地广播捕获变化事件。
|
||||
func (store *exchangeStore) publish(event storeEvent) {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
for subscriber := range store.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneExchange 深拷贝捕获及其请求响应载荷。
|
||||
func cloneExchange(exchange Exchange) Exchange {
|
||||
exchange.Request = clonePayload(exchange.Request)
|
||||
exchange.Response = clonePayload(exchange.Response)
|
||||
return exchange
|
||||
}
|
||||
|
||||
// clonePayload 深拷贝头信息和 Connect 帧切片。
|
||||
func clonePayload(payload Payload) Payload {
|
||||
payload.Headers = append([]Header(nil), payload.Headers...)
|
||||
payload.Frames = append([]FrameView(nil), payload.Frames...)
|
||||
return payload
|
||||
}
|
||||
|
||||
// elapsedMS 计算从开始时间到当前时间的毫秒耗时。
|
||||
func elapsedMS(startedAt time.Time) int64 {
|
||||
if startedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
// sortedHeaders 生成脱敏且按名称排序的请求头列表。
|
||||
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
|
||||
}
|
||||
|
||||
// isSensitiveHeader 判断请求头是否包含鉴权或隐私信息。
|
||||
func isSensitiveHeader(name string) bool {
|
||||
switch httpCanonicalLower(name) {
|
||||
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// httpCanonicalLower 将请求头名称规范化为小写形式。
|
||||
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)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// types.go 定义协议调试器配置、捕获详情和会话摘要模型。
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 默认值限制调试器只监听本机并约束内存捕获规模。
|
||||
const (
|
||||
defaultServiceAddr = "127.0.0.1:9090"
|
||||
defaultUpstreamURL = "https://api2.cursor.sh"
|
||||
debugBasePath = "/__debuger__"
|
||||
defaultMaxExchanges = 200
|
||||
defaultMaxCaptureBytes = 2 << 20
|
||||
defaultMaxFrames = 2000
|
||||
defaultDatabaseName = "cursor-proxy-debugger.db"
|
||||
)
|
||||
|
||||
// Config 控制独立协议调试器的监听和存储限制。
|
||||
type Config struct {
|
||||
// ServiceAddr 是 Cursor API 调试服务监听地址。
|
||||
ServiceAddr string
|
||||
// MaxExchanges 是内存保留的最大请求数。
|
||||
MaxExchanges int
|
||||
// MaxCaptureBytes 是单向载荷保存上限。
|
||||
MaxCaptureBytes int
|
||||
// MaxFrames 是单条流保存的 Connect 帧上限。
|
||||
MaxFrames int
|
||||
// DatabasePath 是 SQLite 捕获数据库路径。
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
// normalized 补齐空值并拒绝无效的容量配置。
|
||||
func (config Config) normalized() Config {
|
||||
if config.ServiceAddr == "" {
|
||||
config.ServiceAddr = defaultServiceAddr
|
||||
}
|
||||
if config.MaxExchanges <= 0 {
|
||||
config.MaxExchanges = defaultMaxExchanges
|
||||
}
|
||||
if config.MaxCaptureBytes <= 0 {
|
||||
config.MaxCaptureBytes = defaultMaxCaptureBytes
|
||||
}
|
||||
if config.MaxFrames <= 0 {
|
||||
config.MaxFrames = defaultMaxFrames
|
||||
}
|
||||
if config.DatabasePath == "" {
|
||||
config.DatabasePath = defaultDatabasePath()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// defaultDatabasePath 返回当前用户配置目录下的默认数据库路径。
|
||||
func defaultDatabasePath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil || configDir == "" {
|
||||
return defaultDatabaseName
|
||||
}
|
||||
return filepath.Join(configDir, "cursor-byok", defaultDatabaseName)
|
||||
}
|
||||
|
||||
// ExchangeSummary 是请求列表使用的紧凑捕获摘要。
|
||||
type ExchangeSummary struct {
|
||||
// ID 是进程内递增的捕获标识。
|
||||
ID string `json:"id"`
|
||||
// StartedAt 是请求开始时间。
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
// Method 是 HTTP 方法。
|
||||
Method string `json:"method"`
|
||||
// URL 是完整请求地址。
|
||||
URL string `json:"url"`
|
||||
// Host 是请求目标主机。
|
||||
Host string `json:"host"`
|
||||
// Path 是 RPC 或 HTTP 路径。
|
||||
Path string `json:"path"`
|
||||
// Status 是 HTTP 响应状态码。
|
||||
Status int `json:"status"`
|
||||
// State 是捕获处理阶段。
|
||||
State string `json:"state"`
|
||||
// DurationMS 是请求总耗时毫秒数。
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
// RequestBytes 是完整请求体字节数。
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
// ResponseBytes 是完整响应体字节数。
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
// RequestID 是协议请求标识。
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
// ConversationID 是关联会话标识。
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
// RequestKind 是解码后的请求消息类型。
|
||||
RequestKind string `json:"requestKind,omitempty"`
|
||||
// ResponseKind 是解码后的响应消息类型。
|
||||
ResponseKind string `json:"responseKind,omitempty"`
|
||||
// FrameCount 是双向 Connect 帧总数。
|
||||
FrameCount int `json:"frameCount"`
|
||||
// Error 是转发或解码错误。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Exchange 保存调试界面展示的请求和响应详情。
|
||||
type Exchange struct {
|
||||
ExchangeSummary
|
||||
// Request 是请求方向载荷。
|
||||
Request Payload `json:"request"`
|
||||
// Response 是响应方向载荷。
|
||||
Response Payload `json:"response"`
|
||||
}
|
||||
|
||||
// Payload 保存请求头、原始副本、解码正文和协议帧。
|
||||
type Payload struct {
|
||||
// Headers 是脱敏且排序稳定的 HTTP 请求头。
|
||||
Headers []Header `json:"headers"`
|
||||
// ContentType 是规范化媒体类型。
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
// ContentCodec 是内容压缩算法。
|
||||
ContentCodec string `json:"contentCodec,omitempty"`
|
||||
// Size 是完整方向载荷字节数。
|
||||
Size int64 `json:"size"`
|
||||
// RawHex 是受限原始副本的十六进制文本。
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
// RawTruncated 表示原始副本达到保存上限。
|
||||
RawTruncated bool `json:"rawTruncated,omitempty"`
|
||||
// DecodedJSON 是格式化后的结构化正文。
|
||||
DecodedJSON string `json:"decodedJson,omitempty"`
|
||||
// DecodedLang 是前端编辑器使用的语言标识。
|
||||
DecodedLang string `json:"decodedLanguage,omitempty"`
|
||||
// DecodeError 是不影响转发的解码错误。
|
||||
DecodeError string `json:"decodeError,omitempty"`
|
||||
// Frames 是 Connect 流的逐帧视图。
|
||||
Frames []FrameView `json:"frames,omitempty"`
|
||||
}
|
||||
|
||||
// Header 是排序稳定的 HTTP 请求头键值对。
|
||||
type Header struct {
|
||||
// Name 是请求头名称。
|
||||
Name string `json:"name"`
|
||||
// Value 是已脱敏的请求头值。
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// FrameView 描述一条 Connect 流式信封。
|
||||
type FrameView struct {
|
||||
// Index 是帧在当前方向的序号。
|
||||
Index int `json:"index"`
|
||||
// Flags 是 Connect 原始标志位。
|
||||
Flags uint8 `json:"flags"`
|
||||
// Length 是解压前帧载荷长度。
|
||||
Length int `json:"length"`
|
||||
// Compressed 表示帧载荷使用压缩。
|
||||
Compressed bool `json:"compressed"`
|
||||
// EndStream 表示帧携带流结束标志。
|
||||
EndStream bool `json:"endStream"`
|
||||
// Kind 是解码后的业务消息类型。
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// MessageType 是 protobuf 完整消息名。
|
||||
MessageType string `json:"messageType,omitempty"`
|
||||
// RequestID 是帧中解析出的请求标识。
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
// JSON 是 protobuf 的 JSON 视图。
|
||||
JSON string `json:"json,omitempty"`
|
||||
// RawHex 是无法解码时保留的载荷文本。
|
||||
RawHex string `json:"rawHex,omitempty"`
|
||||
// Error 是当前帧的解压或解码错误。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// storeEvent 是 SSE 通知使用的最小变化事件。
|
||||
type storeEvent struct {
|
||||
// Type 是捕获记录变化类型。
|
||||
Type string `json:"type"`
|
||||
// ID 是关联捕获标识。
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// ConversationSummary 描述按会话聚合的持久化流量。
|
||||
type ConversationSummary struct {
|
||||
// ConversationID 是会话稳定标识。
|
||||
ConversationID string `json:"conversationId"`
|
||||
// ExchangeCount 是会话捕获记录数。
|
||||
ExchangeCount int `json:"exchangeCount"`
|
||||
// LastStartedAt 是会话最近请求时间。
|
||||
LastStartedAt time.Time `json:"lastStartedAt"`
|
||||
// RequestBytes 是会话累计请求字节数。
|
||||
RequestBytes int64 `json:"requestBytes"`
|
||||
// ResponseBytes 是会话累计响应字节数。
|
||||
ResponseBytes int64 `json:"responseBytes"`
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// web.go 提供调试器只读 API、SSE 更新流和内嵌静态页面。
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// webAssets 保存无需外部文件即可启动的调试页面资源。
|
||||
//
|
||||
//go:embed web/*
|
||||
var webAssets embed.FS
|
||||
|
||||
// newUIHandler 注册只绑定本机界面的调试 API 和静态资源。
|
||||
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("GET /api/conversations", server.handleConversationList)
|
||||
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
|
||||
mux.HandleFunc("GET /api/events", server.handleEvents)
|
||||
assets, _ := fs.Sub(webAssets, "web")
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
mux.Handle("/", fileServer)
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
// handleStatus 返回监听地址、固定上游和数据库状态。
|
||||
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
|
||||
databasePath, databaseError := server.store.status()
|
||||
writeJSON(writer, http.StatusOK, map[string]any{
|
||||
"serviceAddr": server.config.ServiceAddr,
|
||||
"debugPath": debugBasePath + "/",
|
||||
"upstreamURL": server.upstream.String(),
|
||||
"running": true,
|
||||
"databasePath": databasePath,
|
||||
"databaseError": databaseError,
|
||||
})
|
||||
}
|
||||
|
||||
// handleExchangeList 按可选会话标识列出请求摘要。
|
||||
func (server *Server) handleExchangeList(writer http.ResponseWriter, request *http.Request) {
|
||||
conversationID := strings.TrimSpace(request.URL.Query().Get("conversation_id"))
|
||||
summaries, err := server.store.summaries(conversationID)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// handleExchangeDetail 返回单条请求的完整捕获详情。
|
||||
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
|
||||
id := strings.TrimSpace(request.PathValue("id"))
|
||||
exchange, ok, err := server.store.get(id)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, exchange)
|
||||
}
|
||||
|
||||
// handleClearExchanges 清除内存和 SQLite 中的捕获记录。
|
||||
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
|
||||
if err := server.clearExchanges(); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleConversationList 返回持久化流量的会话分组。
|
||||
func (server *Server) handleConversationList(writer http.ResponseWriter, _ *http.Request) {
|
||||
conversations, err := server.store.conversations()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, conversations)
|
||||
}
|
||||
|
||||
// handleEvents 通过 SSE 推送捕获记录变化和保活心跳。
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 写入统一 JSON 响应。
|
||||
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)
|
||||
}
|
||||
|
||||
// securityHeaders 为本地调试页面添加最小浏览器安全策略。
|
||||
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' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; worker-src 'self' blob:")
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。
|
||||
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
|
||||
import { bindEvents, renderPauseState } from "./app_events.js";
|
||||
import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js";
|
||||
const monacoReady = loadMonaco();
|
||||
const editorSlots = {
|
||||
request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
|
||||
response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
|
||||
};
|
||||
|
||||
const state = {
|
||||
status: null,
|
||||
exchanges: [],
|
||||
conversations: [],
|
||||
selectedId: null,
|
||||
selected: null,
|
||||
search: "",
|
||||
requestId: "",
|
||||
conversationId: "",
|
||||
endpoint: "all",
|
||||
bidiMessageKinds: new Set(),
|
||||
showOptions: false,
|
||||
sortOrder: "desc",
|
||||
paused: false,
|
||||
pendingRefresh: false,
|
||||
connection: { connected: false, key: "status.connecting", values: {} },
|
||||
tabs: {
|
||||
request: "body",
|
||||
response: "body",
|
||||
},
|
||||
};
|
||||
|
||||
const elements = {
|
||||
statusDot: document.querySelector("#status-dot"),
|
||||
statusText: document.querySelector("#status-text"),
|
||||
serviceAddress: document.querySelector("#service-address"),
|
||||
upstreamURL: document.querySelector("#upstream-url"),
|
||||
connectionLabel: document.querySelector("#connection-label"),
|
||||
trafficSummary: document.querySelector("#traffic-summary"),
|
||||
searchInput: document.querySelector("#search-input"),
|
||||
requestIdInput: document.querySelector("#request-id-input"),
|
||||
conversationSelect: document.querySelector("#conversation-select"),
|
||||
endpointFilter: document.querySelector("#endpoint-filter"),
|
||||
bidiMessageFilter: document.querySelector("#bidi-message-filter"),
|
||||
bidiMessageOptions: document.querySelector("#bidi-message-options"),
|
||||
showOptionsCheckbox: document.querySelector("#show-options-checkbox"),
|
||||
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.serviceAddress.textContent = `http://${state.status.serviceAddr}`;
|
||||
elements.upstreamURL.textContent = state.status.upstreamURL;
|
||||
elements.showOptionsCheckbox.checked = state.showOptions;
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : "";
|
||||
[state.exchanges, state.conversations] = await Promise.all([
|
||||
fetchJSON(`api/exchanges${query}`),
|
||||
fetchJSON("api/conversations"),
|
||||
]);
|
||||
renderConversationOptions();
|
||||
renderBidiMessageFilter();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function renderConversationOptions() {
|
||||
const selected = state.conversationId;
|
||||
const options = [`<option value="">${escapeHTML(t("filters.allConversations"))}</option>`];
|
||||
for (const conversation of state.conversations) {
|
||||
if (!conversation.conversationId) continue;
|
||||
const label = `${conversation.conversationId} (${conversation.exchangeCount})`;
|
||||
options.push(`<option value="${escapeHTML(conversation.conversationId)}">${escapeHTML(label)}</option>`);
|
||||
}
|
||||
elements.conversationSelect.innerHTML = options.join("");
|
||||
elements.conversationSelect.value = selected;
|
||||
}
|
||||
|
||||
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.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false;
|
||||
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 === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) 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 renderBidiMessageFilter() {
|
||||
const visible = state.endpoint === "bidiappend";
|
||||
elements.bidiMessageFilter.hidden = !visible;
|
||||
if (!visible) elements.bidiMessageFilter.open = false;
|
||||
|
||||
const availableKinds = state.exchanges
|
||||
.filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind)
|
||||
.map((item) => item.requestKind);
|
||||
const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right));
|
||||
elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size
|
||||
? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size })
|
||||
: t("filters.allMessageTypes");
|
||||
elements.bidiMessageOptions.innerHTML = [
|
||||
`<label class="multi-select-option all-option"><input type="checkbox" value=""${state.bidiMessageKinds.size === 0 ? " checked" : ""}><span>${escapeHTML(t("filters.allMessageTypes"))}</span></label>`,
|
||||
...kinds.map((kind) => `<label class="multi-select-option"><input type="checkbox" value="${escapeHTML(kind)}"${state.bidiMessageKinds.has(kind) ? " checked" : ""}><span title="${escapeHTML(kind)}">${escapeHTML(kind)}</span></label>`),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const exchanges = filteredExchanges();
|
||||
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
|
||||
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
|
||||
const groups = new Map();
|
||||
for (const item of exchanges) {
|
||||
const conversationID = item.conversationId || "";
|
||||
if (!groups.has(conversationID)) groups.set(conversationID, []);
|
||||
groups.get(conversationID).push(item);
|
||||
}
|
||||
elements.requestList.innerHTML = [...groups.entries()]
|
||||
.map(([conversationID, items]) => {
|
||||
const label = conversationID || t("groups.unassigned");
|
||||
const header = `<tr class="conversation-group"><td colspan="9"><span>${escapeHTML(t("groups.conversation"))}</span><code title="${escapeHTML(label)}">${escapeHTML(label)}</code><strong>${items.length}</strong></td></tr>`;
|
||||
const rows = items.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("");
|
||||
return header + rows;
|
||||
})
|
||||
.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) {
|
||||
disposeEditor("request");
|
||||
disposeEditor("response");
|
||||
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.classList.remove("editor-active");
|
||||
elements.responseContent.classList.remove("editor-active");
|
||||
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>`;
|
||||
renderPayload("request", item.request, state.tabs.request);
|
||||
renderPayload("response", item.response, state.tabs.response);
|
||||
}
|
||||
|
||||
function renderDetailError(error) {
|
||||
disposeEditor("request");
|
||||
disposeEditor("response");
|
||||
elements.requestContent.classList.remove("editor-active");
|
||||
elements.responseContent.classList.remove("editor-active");
|
||||
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
|
||||
}
|
||||
|
||||
function renderPayload(side, payload, tab) {
|
||||
const container = elements[`${side}Content`];
|
||||
if (!payload) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`);
|
||||
return;
|
||||
}
|
||||
if (tab === "headers") {
|
||||
renderStaticPayload(side, renderHeaders(payload.headers));
|
||||
return;
|
||||
}
|
||||
if (tab === "raw") {
|
||||
if (!payload.rawHex) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noRaw"))}</div>`);
|
||||
return;
|
||||
}
|
||||
renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated));
|
||||
return;
|
||||
}
|
||||
if (tab === "frames") {
|
||||
const document = frameEditorDocument(payload.frames);
|
||||
if (!document) {
|
||||
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`);
|
||||
return;
|
||||
}
|
||||
renderEditorPayload(side, document, "json", "");
|
||||
return;
|
||||
}
|
||||
if (payload.decodedJson) {
|
||||
renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`);
|
||||
return;
|
||||
}
|
||||
if (payload.frames?.length) {
|
||||
renderEditorPayload(side, frameEditorDocument(payload.frames), "json", "");
|
||||
return;
|
||||
}
|
||||
if (payload.decodeError) {
|
||||
renderStaticPayload(side, `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`);
|
||||
return;
|
||||
}
|
||||
container.classList.remove("editor-active");
|
||||
renderStaticPayload(side, `<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 frameEditorDocument(frames = []) {
|
||||
const items = Array.isArray(frames) ? frames : [];
|
||||
if (!items.length) return "";
|
||||
const normalized = items.map((frame) => {
|
||||
let message = frame.rawHex || null;
|
||||
if (frame.json) {
|
||||
try {
|
||||
message = JSON.parse(frame.json);
|
||||
} catch {
|
||||
message = frame.json;
|
||||
}
|
||||
}
|
||||
return {
|
||||
index: frame.index,
|
||||
kind: frame.kind || frame.messageType || t("notices.unknown"),
|
||||
messageType: frame.messageType || undefined,
|
||||
flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`,
|
||||
length: frame.length,
|
||||
compressed: Boolean(frame.compressed),
|
||||
endStream: Boolean(frame.endStream),
|
||||
requestId: frame.requestId || undefined,
|
||||
error: frame.error || undefined,
|
||||
message,
|
||||
};
|
||||
});
|
||||
return JSON.stringify(normalized, null, 2);
|
||||
}
|
||||
|
||||
function renderStaticPayload(side, markup) {
|
||||
disposeEditor(side);
|
||||
const container = elements[`${side}Content`];
|
||||
container.classList.remove("editor-active");
|
||||
container.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderEditorPayload(side, value, language, notices) {
|
||||
const container = elements[`${side}Content`];
|
||||
const slot = editorSlots[side];
|
||||
slot.value = value;
|
||||
slot.language = language;
|
||||
container.classList.add("editor-active");
|
||||
let host = container.querySelector(".editor-host");
|
||||
if (!host || slot.host !== host) {
|
||||
disposeEditor(side);
|
||||
slot.value = value;
|
||||
slot.language = language;
|
||||
container.innerHTML = `<div class="editor-host"><pre class="editor-fallback">${escapeHTML(value)}</pre></div><div class="editor-notices">${notices}</div>`;
|
||||
host = container.querySelector(".editor-host");
|
||||
void createEditor(side, host, value, language);
|
||||
return;
|
||||
}
|
||||
container.querySelector(".editor-notices").innerHTML = notices;
|
||||
const fallback = host.querySelector(".editor-fallback");
|
||||
if (fallback) fallback.textContent = value;
|
||||
updateEditor(slot, value, language);
|
||||
}
|
||||
|
||||
async function createEditor(side, host, value, language) {
|
||||
const slot = editorSlots[side];
|
||||
const token = ++slot.token;
|
||||
slot.host = host;
|
||||
try {
|
||||
const monaco = await monacoReady;
|
||||
if (token !== slot.token || !host.isConnected) return;
|
||||
host.textContent = "";
|
||||
const model = monaco.editor.createModel(slot.value || value, slot.language || language);
|
||||
const editor = monaco.editor.create(host, {
|
||||
model,
|
||||
theme: "vs-dark",
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
automaticLayout: true,
|
||||
fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
minimap: { enabled: false },
|
||||
glyphMargin: false,
|
||||
folding: true,
|
||||
lineNumbersMinChars: 3,
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
renderLineHighlight: "none",
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: "off",
|
||||
padding: { top: 8, bottom: 16 },
|
||||
stickyScroll: { enabled: false },
|
||||
contextmenu: true,
|
||||
});
|
||||
slot.editor = editor;
|
||||
slot.model = model;
|
||||
slot.host = host;
|
||||
} catch {
|
||||
// Monaco 初始化失败时保留文本回退视图。
|
||||
}
|
||||
}
|
||||
|
||||
function updateEditor(slot, value, language) {
|
||||
if (!slot.editor || !slot.model) return;
|
||||
const monaco = window.monaco;
|
||||
if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language);
|
||||
if (slot.model.getValue() === value) return;
|
||||
const viewState = slot.editor.saveViewState();
|
||||
slot.model.setValue(value);
|
||||
if (viewState) slot.editor.restoreViewState(viewState);
|
||||
}
|
||||
|
||||
function disposeEditor(side) {
|
||||
const slot = editorSlots[side];
|
||||
slot.token += 1;
|
||||
slot.editor?.dispose();
|
||||
slot.model?.dispose();
|
||||
slot.editor = null;
|
||||
slot.model = null;
|
||||
slot.host = null;
|
||||
slot.value = "";
|
||||
slot.language = "plaintext";
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const amdRequire = window.require;
|
||||
if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") {
|
||||
reject(new Error("Monaco loader is unavailable"));
|
||||
return;
|
||||
}
|
||||
amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } });
|
||||
amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
|
||||
});
|
||||
}
|
||||
|
||||
function applyLocale() {
|
||||
translateDocument();
|
||||
elements.localeSelect.value = getLocale();
|
||||
renderRuntimeStatus();
|
||||
renderConnectionState();
|
||||
renderPauseState(state, elements);
|
||||
renderConversationOptions();
|
||||
renderBidiMessageFilter();
|
||||
renderList();
|
||||
renderTrafficSummary();
|
||||
renderDetail();
|
||||
}
|
||||
bindEvents({
|
||||
state,
|
||||
elements,
|
||||
fetchJSON,
|
||||
refreshList,
|
||||
refreshDetail,
|
||||
renderList,
|
||||
renderDetail,
|
||||
renderBidiMessageFilter,
|
||||
setConnectionState,
|
||||
applyLocale,
|
||||
});
|
||||
async function bootstrap() {
|
||||
applyLocale();
|
||||
renderDetail();
|
||||
try {
|
||||
await Promise.all([loadStatus(), refreshList()]);
|
||||
connectEvents();
|
||||
} catch (error) {
|
||||
setConnectionState(false, "connection.connectFailed", { message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -1,159 +0,0 @@
|
||||
// app_events.js 绑定调试器筛选、详情、暂停和布局交互事件。
|
||||
import { t } from "./i18n.js";
|
||||
import { currentCopyText } from "./view_helpers.js";
|
||||
|
||||
// renderPauseState 更新暂停按钮的文本和可访问性属性。
|
||||
export function renderPauseState(state, elements) {
|
||||
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));
|
||||
}
|
||||
|
||||
// bindEvents 绑定调试器页面的筛选、详情、暂停和布局交互。
|
||||
export function bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale }) {
|
||||
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.conversationSelect.addEventListener("change", async (event) => {
|
||||
state.conversationId = event.target.value;
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
await refreshList();
|
||||
renderDetail();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
renderBidiMessageFilter();
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.bidiMessageOptions.addEventListener("change", (event) => {
|
||||
const checkbox = event.target.closest('input[type="checkbox"]');
|
||||
if (!checkbox) return;
|
||||
if (!checkbox.value) {
|
||||
state.bidiMessageKinds.clear();
|
||||
} else if (checkbox.checked) {
|
||||
state.bidiMessageKinds.add(checkbox.value);
|
||||
} else {
|
||||
state.bidiMessageKinds.delete(checkbox.value);
|
||||
}
|
||||
renderBidiMessageFilter();
|
||||
elements.bidiMessageFilter.open = true;
|
||||
renderList();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!elements.bidiMessageFilter.contains(event.target)) elements.bidiMessageFilter.open = false;
|
||||
});
|
||||
|
||||
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, state);
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = t("actions.copied");
|
||||
window.setTimeout(() => {
|
||||
button.textContent = t("actions.copy");
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
|
||||
elements.pauseButton.addEventListener("click", async () => {
|
||||
state.paused = !state.paused;
|
||||
elements.pauseButton.classList.toggle("active", state.paused);
|
||||
renderPauseState(state, elements);
|
||||
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
|
||||
if (!state.paused && state.pendingRefresh) {
|
||||
state.pendingRefresh = false;
|
||||
await refreshList();
|
||||
}
|
||||
});
|
||||
|
||||
elements.localeSelect.addEventListener("change", (event) => {
|
||||
setLocale(event.target.value);
|
||||
applyLocale();
|
||||
});
|
||||
|
||||
elements.showOptionsCheckbox.addEventListener("change", (event) => {
|
||||
state.showOptions = event.target.checked;
|
||||
if (!state.showOptions && String(state.selected?.method || "").toUpperCase() === "OPTIONS") {
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
renderDetail();
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
elements.clearButton.addEventListener("click", async () => {
|
||||
await fetchJSON("api/exchanges", { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
state.selected = null;
|
||||
state.conversationId = "";
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// i18n.js 提供协议调试器中英文消息和运行时语言切换。
|
||||
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.pause": "暂停界面更新",
|
||||
"actions.resume": "继续界面更新",
|
||||
"actions.clear": "清空",
|
||||
"actions.copy": "复制",
|
||||
"actions.copied": "已复制",
|
||||
"language.label": "界面语言",
|
||||
"filters.region": "请求过滤器",
|
||||
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
|
||||
"filters.requestIdPlaceholder": "按 Request ID 过滤",
|
||||
"filters.conversation": "按 Conversation ID 查询",
|
||||
"filters.allConversations": "全部会话",
|
||||
"filters.endpoint": "接口过滤",
|
||||
"filters.all": "全部",
|
||||
"filters.allMessageTypes": "全部消息类型",
|
||||
"filters.selectedMessageTypes": "已选 {count} 种消息",
|
||||
"filters.showOptions": "显示 OPTIONS",
|
||||
"filters.sort": "排序方向",
|
||||
"filters.ascending": "正序",
|
||||
"filters.descending": "倒序",
|
||||
"count.requests": "{count} 条",
|
||||
"groups.conversation": "会话",
|
||||
"groups.unassigned": "未关联会话",
|
||||
"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": "Service running",
|
||||
"status.stopped": "Service stopped",
|
||||
"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.conversation": "Query by Conversation ID",
|
||||
"filters.allConversations": "All conversations",
|
||||
"filters.endpoint": "Endpoint filter",
|
||||
"filters.all": "All",
|
||||
"filters.allMessageTypes": "All message types",
|
||||
"filters.selectedMessageTypes": "{count} message types",
|
||||
"filters.showOptions": "Show OPTIONS",
|
||||
"filters.sort": "Sort order",
|
||||
"filters.ascending": "Oldest first",
|
||||
"filters.descending": "Newest first",
|
||||
"count.requests": "{count} requests",
|
||||
"groups.conversation": "Conversation",
|
||||
"groups.unassigned": "Unassigned",
|
||||
"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;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
<!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="service-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>
|
||||
<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>
|
||||
<label class="conversation-filter">
|
||||
<span class="visually-hidden" data-i18n="filters.conversation">会话</span>
|
||||
<select id="conversation-select" aria-label="按 Conversation ID 查询" data-i18n-aria-label="filters.conversation">
|
||||
<option value="" data-i18n="filters.allConversations">全部会话</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
<details id="bidi-message-filter" class="multi-select-filter" hidden>
|
||||
<summary data-i18n="filters.allMessageTypes">全部消息类型</summary>
|
||||
<div id="bidi-message-options" class="multi-select-menu"></div>
|
||||
</details>
|
||||
<label class="checkbox-control">
|
||||
<input id="show-options-checkbox" type="checkbox" />
|
||||
<span data-i18n="filters.showOptions">显示 OPTIONS</span>
|
||||
</label>
|
||||
<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" 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="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="upstream-url"></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs/loader.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
/* styles.css 组合调试器的基础、控件、详情和响应式样式。 */
|
||||
@import url("./styles_base.css");
|
||||
@import url("./styles_controls.css");
|
||||
@import url("./styles_detail.css");
|
||||
@import url("./styles_responsive.css");
|
||||
@@ -1,445 +0,0 @@
|
||||
/* styles.css 定义协议调试器的暗色布局、组件和响应式样式。 */
|
||||
: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 auto 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 {
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
.conversation-filter {
|
||||
flex: 0 1 300px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.conversation-filter select {
|
||||
width: 100%;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0 28px 0 9px;
|
||||
background: #191b1b;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.checkbox-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0 9px;
|
||||
background: #1b1d1d;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-control:has(input:checked) {
|
||||
border-color: #376858;
|
||||
background: var(--accent-soft);
|
||||
color: #bce8d9;
|
||||
}
|
||||
|
||||
.checkbox-control input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.multi-select-filter {
|
||||
position: relative;
|
||||
flex: 0 0 170px;
|
||||
height: 31px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.multi-select-filter[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multi-select-filter summary {
|
||||
overflow: hidden;
|
||||
height: 31px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 7px 28px 0 9px;
|
||||
background: #191b1b;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-filter summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multi-select-filter summary::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
content: "";
|
||||
border: 4px solid transparent;
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
.multi-select-filter[open] summary {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.multi-select-menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 35px;
|
||||
right: 0;
|
||||
overflow: auto;
|
||||
width: 260px;
|
||||
max-height: 320px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 5px;
|
||||
padding: 4px;
|
||||
background: var(--surface-2);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 34%);
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 29px;
|
||||
border-radius: 3px;
|
||||
padding: 0 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.multi-select-option input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.multi-select-option span {
|
||||
overflow: hidden;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-option.all-option {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.request-count {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
/* styles_controls.css 定义调试器筛选栏、请求列表和基础交互控件。 */
|
||||
.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 tbody tr.conversation-group,
|
||||
.request-table tbody tr.conversation-group:hover {
|
||||
cursor: default;
|
||||
background: #202323;
|
||||
}
|
||||
|
||||
.request-table tbody tr.conversation-group td {
|
||||
height: 28px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-group span {
|
||||
margin-right: 8px;
|
||||
color: #9ba19e;
|
||||
}
|
||||
|
||||
.conversation-group code {
|
||||
color: #73becb;
|
||||
}
|
||||
|
||||
.conversation-group strong {
|
||||
margin-left: 8px;
|
||||
color: #78817d;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/* styles_detail.css 定义请求详情、载荷面板和状态提示布局。 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.panel-content.editor-active {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-host {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.editor-fallback {
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 14px 24px;
|
||||
overflow: auto;
|
||||
color: #d4d4d4;
|
||||
font: 12px/1.58 var(--mono);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.editor-notices:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/* styles_responsive.css 定义调试器在窄屏下的响应式布局。 */
|
||||
@media (max-width: 920px) {
|
||||
.app-shell {
|
||||
grid-template-rows: 48px auto 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 auto 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;
|
||||
}
|
||||
|
||||
#upstream-url {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// view_helpers.js 提供调试器界面使用的格式化、转义和复制文本辅助函数。
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
// renderDecodeError 将解码错误转换为安全的提示片段。
|
||||
export function renderDecodeError(error) {
|
||||
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
|
||||
}
|
||||
|
||||
// renderTruncated 生成正文被截断时的提示片段。
|
||||
export function renderTruncated(truncated) {
|
||||
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
|
||||
}
|
||||
|
||||
// formatState 将捕获状态转换为当前语言的展示文本。
|
||||
export function formatState(value) {
|
||||
const key = {
|
||||
pending: "state.pending",
|
||||
streaming: "state.streaming",
|
||||
completed: "state.completed",
|
||||
error: "state.error",
|
||||
}[value];
|
||||
return key ? t(key) : value || "-";
|
||||
}
|
||||
|
||||
// currentCopyText 根据当前标签页提取可复制的载荷文本。
|
||||
export function currentCopyText(side, state) {
|
||||
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 || "";
|
||||
}
|
||||
|
||||
// formatHex 将十六进制载荷按行格式化为调试视图。
|
||||
export 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");
|
||||
}
|
||||
|
||||
// formatBytes 将字节数格式化为人类可读的单位。
|
||||
export 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`;
|
||||
}
|
||||
|
||||
// formatDuration 将毫秒耗时格式化为人类可读的单位。
|
||||
export function formatDuration(value) {
|
||||
const milliseconds = Number(value || 0);
|
||||
if (milliseconds < 1000) return `${milliseconds} ms`;
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
// escapeHTML 转义用户或网络输入,避免插入界面时形成 HTML。
|
||||
export function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
# cursor-proto
|
||||
|
||||
`cursor-proto` 从已安装 Cursor 的 JavaScript bundle 中提取 Protobuf 定义。
|
||||
|
||||
## 目录
|
||||
|
||||
- `extractor/`:Go 提取器。
|
||||
- `scripts/extract.sh`:扫描 Cursor 安装目录并安全更新输出。
|
||||
- `proto/`:提取结果,是项目内唯一的 Proto 输出目录;由脚本重新生成,不提交到 Git。
|
||||
- `scripts/generate.sh`:根据提取结果生成可导入的 Go 消息包。
|
||||
- `gen/`:供其他 Go module 使用的 Go 消息包;由脚本重新生成,不提交到 Git。
|
||||
|
||||
## 使用
|
||||
|
||||
默认从 `/Applications/Cursor.app` 提取:
|
||||
|
||||
```bash
|
||||
./scripts/extract.sh
|
||||
```
|
||||
|
||||
也可以指定 Cursor 应用、bundle 文件和输出目录:
|
||||
|
||||
```bash
|
||||
./scripts/extract.sh /path/to/Cursor.app
|
||||
./scripts/extract.sh /path/to/workbench.desktop.main.js /path/to/output
|
||||
```
|
||||
|
||||
直接运行 Go 提取器时,可重复传入多个 bundle:
|
||||
|
||||
```bash
|
||||
go run ./extractor \
|
||||
-input /path/to/workbench.desktop.main.js \
|
||||
-input /path/to/extensionHostProcess.js \
|
||||
-output ./proto \
|
||||
-strict
|
||||
```
|
||||
|
||||
提取完成后重新生成 Go 消息包:
|
||||
|
||||
```bash
|
||||
./scripts/generate.sh
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
@@ -1,192 +0,0 @@
|
||||
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
|
||||
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
|
||||
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse shorthand T: %v", err)
|
||||
}
|
||||
if field.T != "T" {
|
||||
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
|
||||
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
|
||||
const bundle = `
|
||||
1:(e,t,n)=>{
|
||||
n.d(t,{KS:()=>T,_B:()=>r});
|
||||
var r;
|
||||
class T {}
|
||||
T.typeName="agent.v1.AgentClientMessage";
|
||||
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
|
||||
},
|
||||
2:(e,t,n)=>{
|
||||
var r=n(1);
|
||||
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := []Message{{
|
||||
TypeName: "agent.v1.AgentClientMessage",
|
||||
VarName: "T",
|
||||
InternalName: "T",
|
||||
Package: "agent.v1",
|
||||
Pos: 35,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, 35),
|
||||
}}
|
||||
enums := []Enum{{
|
||||
TypeName: "agent.v1.DiagnosticSeverity",
|
||||
VarName: "r",
|
||||
Package: "agent.v1",
|
||||
Pos: 100,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, 100),
|
||||
}}
|
||||
|
||||
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
|
||||
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
|
||||
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
|
||||
if !ok {
|
||||
t.Fatal("expected webpack export alias to resolve")
|
||||
}
|
||||
if typeName != "agent.v1.AgentClientMessage" {
|
||||
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
|
||||
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
|
||||
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
|
||||
"nt": {
|
||||
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
|
||||
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
|
||||
},
|
||||
}}
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
|
||||
if !ok {
|
||||
t.Fatal("expected cross-package message type to resolve")
|
||||
}
|
||||
if typeName != "git_forge.v1.GetTagResponse" {
|
||||
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
|
||||
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
|
||||
const bundle = `
|
||||
42:(e,t,n)=>{
|
||||
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
|
||||
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
|
||||
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
|
||||
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
|
||||
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := extractMessages(bundle, moduleStarts)
|
||||
enums := extractEnums(bundle, moduleStarts)
|
||||
services := extractServices(bundle, moduleStarts)
|
||||
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("extracted %d messages, want 3", len(messages))
|
||||
}
|
||||
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
|
||||
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
|
||||
}
|
||||
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
|
||||
t.Fatalf("unexpected enums: %#v", enums)
|
||||
}
|
||||
if len(services) != 1 || len(services[0].Methods) != 2 {
|
||||
t.Fatalf("unexpected services: %#v", services)
|
||||
}
|
||||
|
||||
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
|
||||
method := services[0].Methods[0]
|
||||
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
|
||||
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
|
||||
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
|
||||
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
|
||||
}
|
||||
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
|
||||
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
|
||||
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
|
||||
const bundle = `
|
||||
1:(e,t,n)=>{
|
||||
var Timestamp=class TimestampMessage extends Base{};
|
||||
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
|
||||
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
|
||||
}`
|
||||
|
||||
moduleStarts := buildModuleStarts(bundle)
|
||||
messages := extractMessages(bundle, moduleStarts)
|
||||
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
|
||||
if !ok || typeName != "google.protobuf.Timestamp" {
|
||||
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
|
||||
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
|
||||
const bundle = `
|
||||
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
|
||||
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
|
||||
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
|
||||
var Service={typeName:"aiserver.v1.TestService",methods:{}};
|
||||
`
|
||||
messages := []Message{{TypeName: "aiserver.v1.Request"}}
|
||||
services := []Service{{TypeName: "aiserver.v1.TestService"}}
|
||||
|
||||
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
|
||||
if declared != 3 || extracted != 2 {
|
||||
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
|
||||
t.Fatalf("unexpected missing declarations: %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
|
||||
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
|
||||
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
|
||||
services := extractServices(bundle, nil)
|
||||
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
|
||||
t.Fatalf("unexpected services: %#v", services)
|
||||
}
|
||||
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
|
||||
t.Fatalf("unexpected methods: %#v", services[0].Methods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
|
||||
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
|
||||
primary := Message{
|
||||
TypeName: "aiserver.v1.Shared",
|
||||
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
|
||||
}
|
||||
supplemental := Message{
|
||||
TypeName: "aiserver.v1.Shared",
|
||||
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
|
||||
}
|
||||
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
|
||||
|
||||
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged %d messages, want 2", len(merged))
|
||||
}
|
||||
if merged[0].Fields[0].Name != "primary" {
|
||||
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
|
||||
}
|
||||
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
|
||||
t.Fatalf("supplemental-only type missing: %#v", merged)
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// generateProtos 按协议包聚合声明并生成对应文件。
|
||||
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
|
||||
// 按协议包聚合声明。
|
||||
packages := make(map[string]struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
})
|
||||
|
||||
for _, msg := range messages {
|
||||
pkg := packages[msg.Package]
|
||||
pkg.messages = append(pkg.messages, msg)
|
||||
packages[msg.Package] = pkg
|
||||
}
|
||||
|
||||
for _, enum := range enums {
|
||||
pkg := packages[enum.Package]
|
||||
pkg.enums = append(pkg.enums, enum)
|
||||
packages[enum.Package] = pkg
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
pkg := packages[svc.Package]
|
||||
pkg.services = append(pkg.services, svc)
|
||||
packages[svc.Package] = pkg
|
||||
}
|
||||
|
||||
// 建立跨包复制使用的全局类型索引。
|
||||
allMessages := make(map[string]*Message)
|
||||
allEnums := make(map[string]*Enum)
|
||||
|
||||
for pkgName, pkg := range packages {
|
||||
if isGooglePkg(pkgName) {
|
||||
continue
|
||||
}
|
||||
for i := range pkg.messages {
|
||||
msg := &pkg.messages[i]
|
||||
allMessages[msg.TypeName] = msg
|
||||
}
|
||||
for i := range pkg.enums {
|
||||
enum := &pkg.enums[i]
|
||||
allEnums[enum.TypeName] = enum
|
||||
}
|
||||
}
|
||||
|
||||
// 每轮生成前重置已复制类型索引。
|
||||
copiedTypes = make(map[string]map[string]string)
|
||||
|
||||
for pkgName, pkg := range packages {
|
||||
// Google 标准包直接使用官方协议文件。
|
||||
if isGooglePkg(pkgName) {
|
||||
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
|
||||
continue
|
||||
}
|
||||
|
||||
// 把当前包引用的外部类型复制到本地。
|
||||
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
|
||||
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
|
||||
func copyAllExternalTypes(pkgName string, pkg struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
} {
|
||||
if copiedTypes[pkgName] == nil {
|
||||
copiedTypes[pkgName] = make(map[string]string)
|
||||
}
|
||||
|
||||
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
|
||||
localTypes := make(map[string]bool)
|
||||
for _, msg := range pkg.messages {
|
||||
localTypes[msg.ShortName] = true
|
||||
// 空来源名表示该类型原本就在当前包。
|
||||
if copiedTypes[pkgName][msg.ShortName] == "" {
|
||||
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
|
||||
}
|
||||
}
|
||||
for _, enum := range pkg.enums {
|
||||
localTypes[enum.ShortName] = true
|
||||
if copiedTypes[pkgName][enum.ShortName] == "" {
|
||||
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
|
||||
}
|
||||
}
|
||||
|
||||
// 结果先保留当前包原始声明。
|
||||
result := struct {
|
||||
messages []Message
|
||||
enums []Enum
|
||||
services []Service
|
||||
}{
|
||||
messages: append([]Message{}, pkg.messages...),
|
||||
enums: append([]Enum{}, pkg.enums...),
|
||||
services: pkg.services,
|
||||
}
|
||||
|
||||
totalCopied := 0
|
||||
|
||||
// 持续迭代,直到不再发现新的外部依赖。
|
||||
for round := 1; ; round++ {
|
||||
// 收集当前消息中的外部类型引用。
|
||||
neededTypes := make(map[string]bool)
|
||||
|
||||
for _, msg := range result.messages {
|
||||
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||
for _, f := range msg.Fields {
|
||||
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
|
||||
}
|
||||
}
|
||||
for _, svc := range result.services {
|
||||
for _, m := range svc.Methods {
|
||||
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
|
||||
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制本轮新增依赖类型。
|
||||
copiedThisRound := 0
|
||||
for typeName := range neededTypes {
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == pkgName || isGooglePkg(refPkg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 已存在于本地时无需重复复制。
|
||||
if localTypes[shortName] {
|
||||
continue
|
||||
}
|
||||
|
||||
// 复制消息声明。
|
||||
if msg, ok := allMessages[typeName]; ok {
|
||||
msgCopy := *msg
|
||||
msgCopy.Package = pkgName
|
||||
// 保留原始完整类型名,用于生成来源注释。
|
||||
result.messages = append(result.messages, msgCopy)
|
||||
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
|
||||
localTypes[shortName] = true
|
||||
copiedThisRound++
|
||||
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
|
||||
} else if enum, ok := allEnums[typeName]; ok {
|
||||
// 复制枚举声明。
|
||||
enumCopy := *enum
|
||||
enumCopy.Package = pkgName
|
||||
result.enums = append(result.enums, enumCopy)
|
||||
copiedTypes[pkgName][shortName] = typeName
|
||||
localTypes[shortName] = true
|
||||
copiedThisRound++
|
||||
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
|
||||
} else {
|
||||
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
|
||||
copiedTypes[pkgName][shortName] = typeName
|
||||
localTypes[shortName] = true
|
||||
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
|
||||
}
|
||||
}
|
||||
|
||||
totalCopied += copiedThisRound
|
||||
|
||||
if copiedThisRound == 0 {
|
||||
break // 没有新增依赖时结束迭代。
|
||||
}
|
||||
|
||||
if round > 20 {
|
||||
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if totalCopied > 0 {
|
||||
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
|
||||
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||
neededTypes map[string]bool, localTypes map[string]bool) {
|
||||
|
||||
type refWithKind struct {
|
||||
ref string
|
||||
kind string
|
||||
}
|
||||
|
||||
var refs []refWithKind
|
||||
if f.Kind == "message" || f.Kind == "enum" {
|
||||
if v, ok := f.T.(string); ok {
|
||||
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
|
||||
}
|
||||
}
|
||||
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
|
||||
if v, ok := f.MapValueT.(string); ok {
|
||||
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range refs {
|
||||
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 已在当前包中的类型无需收集。
|
||||
if localTypes[shortName] {
|
||||
continue
|
||||
}
|
||||
|
||||
neededTypes[typeName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
|
||||
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
|
||||
neededTypes map[string]bool, localTypes map[string]bool) {
|
||||
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
|
||||
return
|
||||
}
|
||||
|
||||
if localTypes[shortName] {
|
||||
return
|
||||
}
|
||||
|
||||
neededTypes[typeName] = true
|
||||
}
|
||||
|
||||
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
|
||||
var copiedTypes = make(map[string]map[string]string)
|
||||
|
||||
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
|
||||
type TypeNode struct {
|
||||
// Name 是当前嵌套层级的类型名。
|
||||
Name string
|
||||
// Message 保存当前节点的消息声明。
|
||||
Message *Message
|
||||
// Enum 保存当前节点的枚举声明。
|
||||
Enum *Enum
|
||||
// Children 保存下一层嵌套类型。
|
||||
Children map[string]*TypeNode
|
||||
}
|
||||
|
||||
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
|
||||
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
|
||||
imports := make(map[string]bool)
|
||||
|
||||
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
// 仅导入 Google 标准类型。
|
||||
if refPkg == "google.protobuf" {
|
||||
var importFile string
|
||||
switch shortName {
|
||||
case "Struct", "Value", "ListValue", "NullValue":
|
||||
importFile = "google/protobuf/struct.proto"
|
||||
case "Timestamp":
|
||||
importFile = "google/protobuf/timestamp.proto"
|
||||
case "Duration":
|
||||
importFile = "google/protobuf/duration.proto"
|
||||
case "Any":
|
||||
importFile = "google/protobuf/any.proto"
|
||||
case "Empty":
|
||||
importFile = "google/protobuf/empty.proto"
|
||||
case "FieldMask":
|
||||
importFile = "google/protobuf/field_mask.proto"
|
||||
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
|
||||
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
|
||||
importFile = "google/protobuf/wrappers.proto"
|
||||
default:
|
||||
importFile = "google/protobuf/descriptor.proto"
|
||||
}
|
||||
imports[importFile] = true
|
||||
} else if refPkg == "google.rpc" {
|
||||
var importFile string
|
||||
switch shortName {
|
||||
case "Status":
|
||||
importFile = "google/rpc/status.proto"
|
||||
case "Code":
|
||||
importFile = "google/rpc/code.proto"
|
||||
default:
|
||||
importFile = "google/rpc/status.proto"
|
||||
}
|
||||
imports[importFile] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
for _, f := range msg.Fields {
|
||||
if f.Kind == "message" || f.Kind == "enum" {
|
||||
if ref, ok := f.T.(string); ok {
|
||||
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
|
||||
}
|
||||
}
|
||||
// map 值类型也可能引用标准包。
|
||||
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
|
||||
if ref, ok := f.MapValueT.(string); ok {
|
||||
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, svc := range services {
|
||||
for _, m := range svc.Methods {
|
||||
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
|
||||
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
|
||||
}
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// inputPaths 支持命令行重复传入 bundle 路径。
|
||||
type inputPaths []string
|
||||
|
||||
// String 返回已经登记的输入路径列表。
|
||||
func (paths *inputPaths) String() string {
|
||||
return fmt.Sprint([]string(*paths))
|
||||
}
|
||||
|
||||
// Set 追加一个去除空白后的输入路径。
|
||||
func (paths *inputPaths) Set(value string) error {
|
||||
*paths = append(*paths, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// bailIf 在不可恢复错误时打印信息并退出。
|
||||
func bailIf(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// findPrettier 定位可用的 prettier 命令。
|
||||
func findPrettier() (string, error) {
|
||||
// 尝试常见的 prettier 命令名
|
||||
names := []string{"prettier", "prettier.cmd", "npx"}
|
||||
for _, name := range names {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
|
||||
}
|
||||
|
||||
// main 解析参数、保护原始输入并执行协议提取。
|
||||
func main() {
|
||||
// 命令行参数
|
||||
var inputs inputPaths
|
||||
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
|
||||
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
|
||||
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
|
||||
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
|
||||
flag.Parse()
|
||||
|
||||
// 如果没有 -input 参数,尝试从位置参数获取
|
||||
if len(inputs) == 0 && flag.NArg() > 0 {
|
||||
inputs = append(inputs, flag.Args()...)
|
||||
}
|
||||
|
||||
if len(inputs) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
|
||||
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
|
||||
fmt.Fprintln(os.Stderr, "\nExample:")
|
||||
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
|
||||
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, inputPath := range inputs {
|
||||
info, err := os.Stat(inputPath)
|
||||
bailIf(err)
|
||||
if info.IsDir() {
|
||||
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
|
||||
}
|
||||
}
|
||||
|
||||
// 设置输出目录
|
||||
if *outputDir == "" {
|
||||
wd, err := os.Getwd()
|
||||
bailIf(err)
|
||||
*outputDir = filepath.Join(wd, "cursor_proto")
|
||||
}
|
||||
|
||||
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
|
||||
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
|
||||
tempFileNames := make([]string, 0, len(inputs))
|
||||
for _, inputPath := range inputs {
|
||||
originalFile, err := os.Open(inputPath)
|
||||
bailIf(err)
|
||||
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
|
||||
bailIf(err)
|
||||
_, err = io.Copy(tempFile, originalFile)
|
||||
bailIf(err)
|
||||
bailIf(originalFile.Close())
|
||||
bailIf(tempFile.Close())
|
||||
tempFileNames = append(tempFileNames, tempFile.Name())
|
||||
fmt.Printf("Source: %s\n", inputPath)
|
||||
}
|
||||
|
||||
if *skipFormat {
|
||||
fmt.Println("Skipping formatting (--skip-format)")
|
||||
} else if prettierBin, err := findPrettier(); err != nil {
|
||||
fmt.Printf("Warning: %v\n", err)
|
||||
fmt.Println("Skipping formatting, extraction may be less accurate...")
|
||||
} else {
|
||||
fmt.Println("Formatting source bundles (this may take a while)...")
|
||||
for _, tempFileName := range tempFileNames {
|
||||
var prettierCmd *exec.Cmd
|
||||
if filepath.Base(prettierBin) == "npx" {
|
||||
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
|
||||
} else {
|
||||
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
|
||||
}
|
||||
out, formatErr := prettierCmd.CombinedOutput()
|
||||
if formatErr != nil {
|
||||
fmt.Printf("Prettier output: %s\n", string(out))
|
||||
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 运行提取器
|
||||
fmt.Println("Extracting Proto definitions...")
|
||||
SetStrictMode(*strict)
|
||||
ExtractProtosFromFiles(tempFileNames, *outputDir)
|
||||
|
||||
for _, tempFileName := range tempFileNames {
|
||||
_ = os.Remove(tempFileName)
|
||||
}
|
||||
|
||||
fmt.Printf("\nOutput directory: %s\n", *outputDir)
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
// messages.go 解析消息声明、字段数组和字段类型信息。
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractMessages 从多种 bundle 语法中提取消息声明。
|
||||
func extractMessages(text string, moduleStarts []int) []Message {
|
||||
var messages []Message
|
||||
messageExists := func(typeName, varName string) bool {
|
||||
for _, existing := range messages {
|
||||
if existing.TypeName == typeName && existing.VarName == varName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 形式一:变量引用继承基类并在类体中声明 typeName 和 fields。
|
||||
// 先找所有 "变量名 = class 内部类名" 定义
|
||||
// JS 变量名可以包含 $ 符号,如 B$e, qg 等
|
||||
// 需要同时捕获外部变量名和内部类名,因为字段引用可能用任一个
|
||||
classDefRe := regexp.MustCompile(`([\w$]+)\s*=\s*class\s+([\w$]+)\s+extends\s+[\w$.]+\s*\{`)
|
||||
classMatches := classDefRe.FindAllStringSubmatchIndex(text, -1)
|
||||
|
||||
// 从任意包的 this.typeName 字段读取完整类型名。
|
||||
typeNameRe := regexp.MustCompile(`this\.typeName\s*=\s*"([\w.]+)"`)
|
||||
|
||||
// 从 this.fields 的 newFieldList 回调读取字段数组。
|
||||
fieldsRe := regexp.MustCompile(`this\.fields\s*=\s*\w+(?:\.proto3)?\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
|
||||
|
||||
for _, classMatch := range classMatches {
|
||||
varName := text[classMatch[2]:classMatch[3]]
|
||||
internalName := text[classMatch[4]:classMatch[5]]
|
||||
classStart := classMatch[0]
|
||||
|
||||
// 找到类的结束位置(匹配大括号)
|
||||
classEnd := findClassEnd(text, classMatch[1]-1)
|
||||
if classEnd == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
classBody := text[classStart:classEnd]
|
||||
|
||||
// 在类体内查找 typeName
|
||||
typeMatch := typeNameRe.FindStringSubmatch(classBody)
|
||||
if typeMatch == nil {
|
||||
continue
|
||||
}
|
||||
typeName := typeMatch[1]
|
||||
|
||||
// 在类体内查找 fields
|
||||
fieldsMatch := fieldsRe.FindStringIndex(classBody)
|
||||
if fieldsMatch == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到 fields 数组的开始位置
|
||||
bracketPos := classStart + fieldsMatch[1] - 1
|
||||
fields := extractFieldArray(text, bracketPos)
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
msg := Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
InternalName: internalName,
|
||||
Fields: fields,
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: classStart,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, classStart),
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
// 形式二:匹配转译或压缩 bundle 中连续赋值的消息声明。
|
||||
// 例如 i.runtime=n.proto3,i.typeName="agent.v1.McpArgs",i.fields=n.proto3.util.newFieldList(()=>[{...}])。
|
||||
assignmentRe := regexp.MustCompile(`([\w$]+)\.typeName\s*=\s*"([\w.]+)"\s*,\s*[\w$]+\.fields\s*=\s*\w+(?:\.\w+)*\.util\.newFieldList\s*\(\s*\(\s*\)\s*=>\s*\[`)
|
||||
assignmentMatches := assignmentRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range assignmentMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
|
||||
// 跳过已经由类体形式提取的重复消息。
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 正则停在左方括号之前,从匹配尾部定位数组起点。
|
||||
start := m[1] - 1
|
||||
if start < 0 || start >= len(text) || text[start] != '[' {
|
||||
continue
|
||||
}
|
||||
fields := extractFieldArray(text, start)
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
InternalName: "",
|
||||
Fields: fields,
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
// 形式三:匹配现代 @bufbuild/protobuf 工厂调用。
|
||||
// 例如 Req=A.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{...}])。
|
||||
messageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\(\s*\)\s*=>\s*\[`)
|
||||
factoryMatches := messageFactoryRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range factoryMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
bracketStart := m[1] - 1
|
||||
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Fields: extractFieldArray(text, bracketStart),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
// 空消息直接传字段数组,不使用延迟回调。
|
||||
// 例如 Res=A.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[])。
|
||||
emptyMessageFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeMessageType\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
|
||||
emptyFactoryMatches := emptyMessageFactoryRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, m := range emptyFactoryMatches {
|
||||
varName := text[m[2]:m[3]]
|
||||
typeName := text[m[4]:m[5]]
|
||||
if messageExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
bracketStart := m[1] - 1
|
||||
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
messages = append(messages, Message{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Fields: extractFieldArray(text, bracketStart),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: m[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, m[0]),
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// findClassEnd 查找类定义的配对右花括号。
|
||||
func findClassEnd(text string, openBrace int) int {
|
||||
depth := 0
|
||||
for i := openBrace; i < len(text); i++ {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// extractFieldArray 从左方括号位置解析完整字段数组。
|
||||
func extractFieldArray(text string, start int) []Field {
|
||||
// 查找字段数组的配对右方括号。
|
||||
depth := 0
|
||||
end := start
|
||||
for i := start; i < len(text); i++ {
|
||||
if text[i] == '[' {
|
||||
depth++
|
||||
} else if text[i] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrayText := text[start:end]
|
||||
|
||||
// 按每个花括号块解析独立字段对象。
|
||||
var fields []Field
|
||||
|
||||
// 依次查找字段对象。
|
||||
fieldObjects := extractFieldObjects(arrayText)
|
||||
|
||||
for _, fieldObj := range fieldObjects {
|
||||
field, parseErr := parseFieldObject(fieldObj)
|
||||
if parseErr != nil {
|
||||
activeDiagnostics.addSkippedField(fieldObj, parseErr)
|
||||
continue
|
||||
}
|
||||
activeDiagnostics.addParsedField()
|
||||
fields = append(fields, *field)
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractFieldObjects 从数组文本中提取独立字段对象。
|
||||
func extractFieldObjects(arrayText string) []string {
|
||||
var objects []string
|
||||
depth := 0
|
||||
start := -1
|
||||
|
||||
for i := 0; i < len(arrayText); i++ {
|
||||
if arrayText[i] == '{' {
|
||||
if depth == 0 {
|
||||
start = i
|
||||
}
|
||||
depth++
|
||||
} else if arrayText[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 && start >= 0 {
|
||||
objects = append(objects, arrayText[start:i+1])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return objects
|
||||
}
|
||||
|
||||
// parseFieldObject 解析包含编号、名称、类型和修饰符的单个字段对象。
|
||||
func parseFieldObject(obj string) (*Field, error) {
|
||||
// 提取字段编号。
|
||||
noMatch := noRe.FindStringSubmatch(obj)
|
||||
if noMatch == nil {
|
||||
return nil, errors.New("missing field no")
|
||||
}
|
||||
no, _ := strconv.Atoi(noMatch[1])
|
||||
|
||||
// 提取字段名称。
|
||||
nameMatch := nameRe.FindStringSubmatch(obj)
|
||||
if nameMatch == nil {
|
||||
return nil, errors.New("missing field name")
|
||||
}
|
||||
name := strings.TrimSpace(nameMatch[1])
|
||||
if !fieldNameRe.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid field name: %s", name)
|
||||
}
|
||||
|
||||
// 提取字段类别。
|
||||
kindMatch := kindRe.FindStringSubmatch(obj)
|
||||
if kindMatch == nil {
|
||||
return nil, errors.New("missing field kind")
|
||||
}
|
||||
kind := strings.TrimSpace(kindMatch[1])
|
||||
|
||||
field := &Field{
|
||||
No: no,
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
}
|
||||
|
||||
// 类型 T 可以是标量编号、变量名或 getEnumType 枚举调用。
|
||||
|
||||
// 枚举优先匹配 getEnumType 调用。
|
||||
if enumMatch := enumTypeRe.FindStringSubmatch(obj); enumMatch != nil {
|
||||
field.T = enumMatch[1]
|
||||
} else {
|
||||
// 其余类型匹配普通 T 属性值。
|
||||
if tMatch := tRe.FindStringSubmatch(obj); tMatch != nil {
|
||||
if t, err := strconv.Atoi(tMatch[1]); err == nil {
|
||||
field.T = t
|
||||
} else {
|
||||
field.T = tMatch[1]
|
||||
}
|
||||
} else if shorthandTRe.MatchString(obj) {
|
||||
field.T = "T"
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 oneof 分组。
|
||||
if oneofMatch := oneofRe.FindStringSubmatch(obj); oneofMatch != nil {
|
||||
candidate := strings.TrimSpace(oneofMatch[1])
|
||||
if oneofNameRe.MatchString(candidate) {
|
||||
field.Oneof = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 repeated;压缩 JS 中 !0 表示真。
|
||||
if repeatedRe.MatchString(obj) {
|
||||
field.Repeated = true
|
||||
}
|
||||
|
||||
// 仅在当前字段对象内检查 optional。
|
||||
if optRe.MatchString(obj) {
|
||||
field.Opt = true
|
||||
}
|
||||
|
||||
// map 字段通过 K 键类型和 V 值描述共同表示。
|
||||
if field.Kind == "map" {
|
||||
// 提取 map 键类型。
|
||||
if keyMatch := keyRe.FindStringSubmatch(obj); keyMatch != nil {
|
||||
field.MapKey, _ = strconv.Atoi(keyMatch[1])
|
||||
}
|
||||
|
||||
// 提取 map 值类型,兼容属性顺序变化。
|
||||
if valueMatch := mapValueRe.FindStringSubmatch(obj); valueMatch != nil {
|
||||
valueObj := valueMatch[1]
|
||||
if kindMatch := mapValueKRe.FindStringSubmatch(valueObj); kindMatch != nil {
|
||||
field.MapValueKind = kindMatch[1]
|
||||
}
|
||||
if tMatch := mapValueTRe.FindStringSubmatch(valueObj); tMatch != nil {
|
||||
if t, err := strconv.Atoi(tMatch[1]); err == nil {
|
||||
field.MapValueT = t
|
||||
} else {
|
||||
field.MapValueT = tMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return field, nil
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// modules.go 扫描模块边界、合并声明并执行提取结果校验。
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jhump/protoreflect/desc"
|
||||
"github.com/jhump/protoreflect/desc/protoparse"
|
||||
)
|
||||
|
||||
// moduleStartRe 匹配 Webpack 数字模块的函数起点。
|
||||
var moduleStartRe = regexp.MustCompile(`(?:^|,)\s*(\d+)\s*:\s*(?:function\s*\(\s*[\w$,\s]*\s*\)|\(\s*[\w$,\s]*\s*\)\s*=>)\s*\{`)
|
||||
|
||||
// buildModuleStarts 收集 bundle 内全部模块起始位置。
|
||||
func buildModuleStarts(text string) []int {
|
||||
matches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
||||
starts := make([]int, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
starts = append(starts, match[0])
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
// moduleStartForPos 查找指定源码位置所属的模块起点。
|
||||
func moduleStartForPos(moduleStarts []int, pos int) int {
|
||||
if len(moduleStarts) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := sort.Search(len(moduleStarts), func(i int) bool {
|
||||
return moduleStarts[i] > pos
|
||||
}) - 1
|
||||
if index < 0 {
|
||||
return 0
|
||||
}
|
||||
return moduleStarts[index]
|
||||
}
|
||||
|
||||
// buildModuleImportIndex 建立模块局部变量到导入模块编号的映射。
|
||||
func buildModuleImportIndex(text string, moduleStarts []int) map[int]map[string]int {
|
||||
if len(moduleStarts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
moduleMatches := moduleStartRe.FindAllStringSubmatchIndex(text, -1)
|
||||
moduleStartByID := make(map[string]int, len(moduleMatches))
|
||||
for _, match := range moduleMatches {
|
||||
moduleStartByID[text[match[2]:match[3]]] = match[0]
|
||||
}
|
||||
|
||||
importsByModule := make(map[int]map[string]int)
|
||||
for index, moduleStart := range moduleStarts {
|
||||
moduleEnd := len(text)
|
||||
if index+1 < len(moduleStarts) {
|
||||
moduleEnd = moduleStarts[index+1]
|
||||
}
|
||||
body := text[moduleStart:moduleEnd]
|
||||
for _, match := range moduleImportRe.FindAllStringSubmatch(body, -1) {
|
||||
targetModuleStart, ok := moduleStartByID[match[2]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if importsByModule[moduleStart] == nil {
|
||||
importsByModule[moduleStart] = make(map[string]int)
|
||||
}
|
||||
importsByModule[moduleStart][match[1]] = targetModuleStart
|
||||
}
|
||||
}
|
||||
return importsByModule
|
||||
}
|
||||
|
||||
// ExtractProtosFromFiles 分别提取各 bundle,规范化类型引用后按全限定名合并。
|
||||
// 多个 bundle 出现同名声明时优先保留靠前输入。
|
||||
func ExtractProtosFromFiles(inputFiles []string, outputDir string) {
|
||||
activeDiagnostics = newExtractionDiagnostics()
|
||||
defer func() {
|
||||
activeDiagnostics = nil
|
||||
}()
|
||||
|
||||
var allMessages []Message
|
||||
var allEnums []Enum
|
||||
var allServices []Service
|
||||
for _, inputFile := range inputFiles {
|
||||
content, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading file %s: %v\n", inputFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
text := string(content)
|
||||
moduleStarts := buildModuleStarts(text)
|
||||
aliases := buildAliasIndex(text, moduleStarts)
|
||||
exportAliases := buildWebpackExportAliasIndex(text, moduleStarts)
|
||||
|
||||
messages := extractMessages(text, moduleStarts)
|
||||
enums := extractEnums(text, moduleStarts)
|
||||
services := extractServices(text, moduleStarts)
|
||||
declared, extracted, missing := declarationCoverage(text, messages, enums, services)
|
||||
activeDiagnostics.declaredTypes += declared
|
||||
activeDiagnostics.extractedTypes += extracted
|
||||
activeDiagnostics.missingDeclarations = append(activeDiagnostics.missingDeclarations, missing...)
|
||||
|
||||
resolver := newTypeResolver(messages, enums, aliases, exportAliases)
|
||||
resolver.moduleImports = buildModuleImportIndex(text, moduleStarts)
|
||||
normalizeTypeReferences(messages, services, resolver)
|
||||
|
||||
allMessages = append(allMessages, messages...)
|
||||
allEnums = append(allEnums, enums...)
|
||||
allServices = append(allServices, services...)
|
||||
}
|
||||
|
||||
messages := mergeMessagesByTypeName(allMessages)
|
||||
enums := mergeEnumsByTypeName(allEnums)
|
||||
services := mergeServicesByTypeName(allServices)
|
||||
for _, msg := range messages {
|
||||
if len(msg.Fields) == 0 {
|
||||
activeDiagnostics.emptyMessages = append(activeDiagnostics.emptyMessages, msg.TypeName)
|
||||
}
|
||||
}
|
||||
sort.Strings(activeDiagnostics.missingDeclarations)
|
||||
activeDiagnostics.missingDeclarations = compactStrings(activeDiagnostics.missingDeclarations)
|
||||
|
||||
resolver := newTypeResolver(messages, enums, nil, nil)
|
||||
|
||||
generateProtos(messages, enums, services, resolver, outputDir)
|
||||
|
||||
validateErr := validateGeneratedProtos(outputDir, activeDiagnostics)
|
||||
|
||||
printDiagnosticsSummary(activeDiagnostics)
|
||||
|
||||
if strictExtractionValidation && hasValidationFailure(activeDiagnostics, validateErr) {
|
||||
if validateErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Validation failed: %v\n", validateErr)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if validateErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Validation warning: %v\n", validateErr)
|
||||
}
|
||||
|
||||
fmt.Printf("提取完成: %d 个消息, %d 个枚举, %d 个服务\n", len(messages), len(enums), len(services))
|
||||
}
|
||||
|
||||
// normalizeTypeReferences 把字段和方法引用统一转换为全限定类型名。
|
||||
func normalizeTypeReferences(messages []Message, services []Service, resolver *TypeResolver) {
|
||||
resolve := func(ref any, contextPos int, moduleStart int, pkg string, kind string) any {
|
||||
symbol, ok := ref.(string)
|
||||
if !ok || strings.TrimSpace(symbol) == "" {
|
||||
return ref
|
||||
}
|
||||
if typeName, resolved := resolver.ResolveTypeName(symbol, contextPos, moduleStart, pkg, kind); resolved {
|
||||
return typeName
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
for messageIndex := range messages {
|
||||
message := &messages[messageIndex]
|
||||
for fieldIndex := range message.Fields {
|
||||
field := &message.Fields[fieldIndex]
|
||||
if field.Kind == "message" || field.Kind == "enum" {
|
||||
field.T = resolve(field.T, message.Pos, message.ModuleStart, message.Package, field.Kind)
|
||||
}
|
||||
if field.Kind == "map" && (field.MapValueKind == "message" || field.MapValueKind == "enum") {
|
||||
field.MapValueT = resolve(field.MapValueT, message.Pos, message.ModuleStart, message.Package, field.MapValueKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for serviceIndex := range services {
|
||||
service := &services[serviceIndex]
|
||||
for methodIndex := range service.Methods {
|
||||
method := &service.Methods[methodIndex]
|
||||
if typeName, ok := resolve(method.InputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
|
||||
method.InputType = typeName
|
||||
}
|
||||
if typeName, ok := resolve(method.OutputType, service.Pos, service.ModuleStart, service.Package, "message").(string); ok {
|
||||
method.OutputType = typeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeMessagesByTypeName 按全限定名合并消息并保留首次声明。
|
||||
func mergeMessagesByTypeName(messages []Message) []Message {
|
||||
seen := make(map[string]bool)
|
||||
merged := make([]Message, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
if seen[message.TypeName] {
|
||||
continue
|
||||
}
|
||||
seen[message.TypeName] = true
|
||||
merged = append(merged, message)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// mergeEnumsByTypeName 按全限定名合并枚举并保留首次声明。
|
||||
func mergeEnumsByTypeName(enums []Enum) []Enum {
|
||||
seen := make(map[string]bool)
|
||||
merged := make([]Enum, 0, len(enums))
|
||||
for _, enum := range enums {
|
||||
if seen[enum.TypeName] {
|
||||
continue
|
||||
}
|
||||
seen[enum.TypeName] = true
|
||||
merged = append(merged, enum)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// mergeServicesByTypeName 按全限定名合并服务并保留首次声明。
|
||||
func mergeServicesByTypeName(services []Service) []Service {
|
||||
seen := make(map[string]bool)
|
||||
merged := make([]Service, 0, len(services))
|
||||
for _, service := range services {
|
||||
if seen[service.TypeName] {
|
||||
continue
|
||||
}
|
||||
seen[service.TypeName] = true
|
||||
merged = append(merged, service)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// compactStrings 清理、去重并排序诊断字符串。
|
||||
func compactStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
compacted := values[:1]
|
||||
for _, value := range values[1:] {
|
||||
if value != compacted[len(compacted)-1] {
|
||||
compacted = append(compacted, value)
|
||||
}
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
// hasValidationFailure 判断诊断结果是否达到失败条件。
|
||||
func hasValidationFailure(diag *extractionDiagnostics, validateErr error) bool {
|
||||
if validateErr != nil {
|
||||
return true
|
||||
}
|
||||
if diag == nil {
|
||||
return false
|
||||
}
|
||||
if diag.skippedFieldObjects > 0 {
|
||||
return true
|
||||
}
|
||||
if len(diag.unresolvedTypeRefs) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(diag.placeholderHits) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(diag.missingDeclarations) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// printDiagnosticsSummary 输出提取覆盖率和异常样本摘要。
|
||||
func printDiagnosticsSummary(diag *extractionDiagnostics) {
|
||||
if diag == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"诊断汇总: fields %d/%d 解析成功, declarations %d/%d 已提取, skipped=%d, unresolved=%d, placeholders=%d, empty_messages=%d\n",
|
||||
diag.parsedFieldObjects,
|
||||
diag.totalFieldObjects,
|
||||
diag.extractedTypes,
|
||||
diag.declaredTypes,
|
||||
diag.skippedFieldObjects,
|
||||
len(diag.unresolvedTypeRefs),
|
||||
len(diag.placeholderHits),
|
||||
len(diag.emptyMessages),
|
||||
)
|
||||
|
||||
if diag.skippedFieldObjects > 0 && len(diag.skippedFieldSamples) > 0 {
|
||||
fmt.Println("字段解析失败样例:")
|
||||
for _, sample := range diag.skippedFieldSamples {
|
||||
fmt.Printf(" - %s\n", sample)
|
||||
}
|
||||
}
|
||||
|
||||
if len(diag.unresolvedTypeRefs) > 0 {
|
||||
keys := make([]string, 0, len(diag.unresolvedTypeRefs))
|
||||
for key := range diag.unresolvedTypeRefs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Println("未解析类型引用:")
|
||||
for _, key := range keys {
|
||||
fmt.Printf(" - %s (%d)\n", key, diag.unresolvedTypeRefs[key])
|
||||
}
|
||||
}
|
||||
|
||||
if len(diag.placeholderHits) > 0 {
|
||||
fmt.Println("占位字段命中:")
|
||||
for i, hit := range diag.placeholderHits {
|
||||
if i >= 20 {
|
||||
fmt.Printf(" - ... and %d more\n", len(diag.placeholderHits)-20)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", hit)
|
||||
}
|
||||
}
|
||||
|
||||
if len(diag.missingDeclarations) > 0 {
|
||||
fmt.Println("未提取的 Proto 声明:")
|
||||
for i, typeName := range diag.missingDeclarations {
|
||||
if i >= 20 {
|
||||
fmt.Printf(" - ... and %d more\n", len(diag.missingDeclarations)-20)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", typeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// declarationCoverage 比较 bundle 声明数量与实际提取数量。
|
||||
func declarationCoverage(text string, messages []Message, enums []Enum, services []Service) (int, int, []string) {
|
||||
declared := make(map[string]bool)
|
||||
collect := func(re *regexp.Regexp) {
|
||||
for _, match := range re.FindAllStringSubmatch(text, -1) {
|
||||
typeName := strings.TrimSpace(match[1])
|
||||
pkg, _ := parseTypeName(typeName)
|
||||
if typeName != "" && !isGooglePkg(pkg) {
|
||||
declared[typeName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(typeNameDeclarationRe)
|
||||
collect(serviceDeclarationRe)
|
||||
collect(messageDeclarationRe)
|
||||
collect(enumDeclarationRe)
|
||||
collect(legacyEnumDeclarationRe)
|
||||
|
||||
extracted := make(map[string]bool)
|
||||
for _, message := range messages {
|
||||
extracted[message.TypeName] = true
|
||||
}
|
||||
for _, enum := range enums {
|
||||
extracted[enum.TypeName] = true
|
||||
}
|
||||
for _, service := range services {
|
||||
extracted[service.TypeName] = true
|
||||
}
|
||||
|
||||
matched := 0
|
||||
missing := make([]string, 0)
|
||||
for typeName := range declared {
|
||||
if extracted[typeName] {
|
||||
matched++
|
||||
continue
|
||||
}
|
||||
missing = append(missing, typeName)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return len(declared), matched, missing
|
||||
}
|
||||
|
||||
// validateGeneratedProtos 检查生成文件语法占位和关键 Agent 结构。
|
||||
func validateGeneratedProtos(outputDir string, diag *extractionDiagnostics) error {
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read output dir failed: %w", err)
|
||||
}
|
||||
|
||||
protoFiles := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(name, ".proto") {
|
||||
protoFiles = append(protoFiles, name)
|
||||
}
|
||||
}
|
||||
if len(protoFiles) == 0 {
|
||||
return errors.New("no generated proto files found")
|
||||
}
|
||||
sort.Strings(protoFiles)
|
||||
|
||||
for _, file := range protoFiles {
|
||||
body, readErr := os.ReadFile(filepath.Join(outputDir, file))
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read generated proto failed: %s: %w", file, readErr)
|
||||
}
|
||||
lines := strings.Split(string(body), "\n")
|
||||
for idx, line := range lines {
|
||||
if placeholderRe.MatchString(line) && diag != nil {
|
||||
hit := fmt.Sprintf("%s:%d: %s", file, idx+1, strings.TrimSpace(line))
|
||||
diag.placeholderHits = append(diag.placeholderHits, hit)
|
||||
}
|
||||
}
|
||||
if err := validateRequiredAgentShapes(file, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
parser := protoparse.Parser{
|
||||
ImportPaths: []string{outputDir},
|
||||
LookupImport: desc.LoadFileDescriptor,
|
||||
}
|
||||
if _, parseErr := parser.ParseFiles(protoFiles...); parseErr != nil {
|
||||
return fmt.Errorf("parse generated proto failed: %w", parseErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequiredAgentShapes 校验 Agent 流控消息的必要字段形状。
|
||||
func validateRequiredAgentShapes(file string, body string) error {
|
||||
if strings.Contains(body, "message ExecClientControlMessage") && !streamCloseRe.MatchString(body) {
|
||||
return fmt.Errorf("%s: ExecClientControlMessage.stream_close must be ExecClientStreamClose", file)
|
||||
}
|
||||
if strings.Contains(body, "message ShellStream") && !shellStdoutRe.MatchString(body) {
|
||||
return fmt.Errorf("%s: ShellStream.stdout must be ShellStreamStdout", file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
// renderer.go 把协议声明树渲染为稳定的 proto 文本。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateProtoFile 把单个协议包的声明渲染并写入文件。
|
||||
func generateProtoFile(pkgName string, messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
|
||||
// 先收集全部跨包标准依赖。
|
||||
imports := collectImports(pkgName, messages, services, resolver)
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(`syntax = "proto3";` + "\n\n")
|
||||
sb.WriteString(fmt.Sprintf("package %s;\n\n", pkgName))
|
||||
|
||||
// 按稳定顺序写入 import。
|
||||
if len(imports) > 0 {
|
||||
sortedImports := make([]string, 0, len(imports))
|
||||
for imp := range imports {
|
||||
sortedImports = append(sortedImports, imp)
|
||||
}
|
||||
sort.Strings(sortedImports)
|
||||
for _, imp := range sortedImports {
|
||||
sb.WriteString(fmt.Sprintf("import \"%s\";\n", imp))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
goPackagePath := strings.ReplaceAll(pkgName, ".", "/")
|
||||
goPackageName := strings.ReplaceAll(pkgName, ".", "")
|
||||
sb.WriteString(fmt.Sprintf(`option go_package = "github.com/leookun/cursor-byok/cursor-proto/gen/%s;%s";`+"\n\n", goPackagePath, goPackageName))
|
||||
|
||||
// 建立嵌套类型树。
|
||||
root := &TypeNode{Children: make(map[string]*TypeNode)}
|
||||
|
||||
for i := range messages {
|
||||
msg := &messages[i]
|
||||
path := getNestedPath(msg.ShortName)
|
||||
insertMessage(root, path, msg)
|
||||
}
|
||||
|
||||
for i := range enums {
|
||||
enum := &enums[i]
|
||||
path := getNestedPath(enum.ShortName)
|
||||
insertEnum(root, path, enum)
|
||||
}
|
||||
|
||||
// 写入全部顶层类型。
|
||||
writeTypeTree(root, &sb, resolver, 0, pkgName)
|
||||
|
||||
// 写入服务声明。
|
||||
sort.Slice(services, func(i, j int) bool {
|
||||
return services[i].ShortName < services[j].ShortName
|
||||
})
|
||||
|
||||
for _, svc := range services {
|
||||
// 写入服务来源注释。
|
||||
sb.WriteString(fmt.Sprintf("// Source: %s (var: %s)\n", svc.TypeName, svc.VarName))
|
||||
sb.WriteString(fmt.Sprintf("service %s {\n", svc.ShortName))
|
||||
for _, m := range svc.Methods {
|
||||
inputType := resolveMethodType(m.InputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
|
||||
outputType := resolveMethodType(m.OutputType, resolver, pkgName, svc.Pos, svc.ModuleStart)
|
||||
|
||||
switch m.Kind {
|
||||
case "ServerStreaming":
|
||||
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (stream %s) {}\n", m.Name, inputType, outputType))
|
||||
case "ClientStreaming":
|
||||
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (%s) {}\n", m.Name, inputType, outputType))
|
||||
case "BiDiStreaming":
|
||||
sb.WriteString(fmt.Sprintf(" rpc %s(stream %s) returns (stream %s) {}\n", m.Name, inputType, outputType))
|
||||
default: // 默认为一元调用。
|
||||
sb.WriteString(fmt.Sprintf(" rpc %s(%s) returns (%s) {}\n", m.Name, inputType, outputType))
|
||||
}
|
||||
}
|
||||
sb.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
// 每个协议包写入扁平输出目录中的单个文件。
|
||||
fileName := strings.ReplaceAll(pkgName, ".", "_") + ".proto"
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
os.WriteFile(filePath, []byte(sb.String()), 0644)
|
||||
fmt.Printf("Generated: %s (%d messages, %d enums, %d services)\n", filePath, len(messages), len(enums), len(services))
|
||||
}
|
||||
|
||||
// resolveMethodType 解析方法消息类型并处理本地复制类型。
|
||||
func resolveMethodType(ref string, resolver *TypeResolver, currentPkg string, contextPos int, contextModuleStart int) string {
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
|
||||
if !ok {
|
||||
activeDiagnostics.addUnresolvedType("method:" + ref)
|
||||
return fallbackTypeToken(ref)
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
if refPkg == currentPkg || refPkg == "" {
|
||||
return shortName
|
||||
}
|
||||
// 检查类型是否由其他包复制到当前包。
|
||||
if copied := copiedTypes[currentPkg]; copied != nil {
|
||||
if _, isCopied := copied[shortName]; isCopied {
|
||||
return shortName
|
||||
}
|
||||
}
|
||||
return refPkg + "." + shortName
|
||||
}
|
||||
|
||||
// insertMessage 把消息插入嵌套类型树。
|
||||
func insertMessage(node *TypeNode, path []string, msg *Message) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
name := path[0]
|
||||
if node.Children == nil {
|
||||
node.Children = make(map[string]*TypeNode)
|
||||
}
|
||||
|
||||
child, exists := node.Children[name]
|
||||
if !exists {
|
||||
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
|
||||
node.Children[name] = child
|
||||
}
|
||||
|
||||
if len(path) == 1 {
|
||||
child.Message = msg
|
||||
} else {
|
||||
insertMessage(child, path[1:], msg)
|
||||
}
|
||||
}
|
||||
|
||||
// insertEnum 把枚举插入嵌套类型树。
|
||||
func insertEnum(node *TypeNode, path []string, enum *Enum) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
name := path[0]
|
||||
if node.Children == nil {
|
||||
node.Children = make(map[string]*TypeNode)
|
||||
}
|
||||
|
||||
child, exists := node.Children[name]
|
||||
if !exists {
|
||||
child = &TypeNode{Name: name, Children: make(map[string]*TypeNode)}
|
||||
node.Children[name] = child
|
||||
}
|
||||
|
||||
if len(path) == 1 {
|
||||
child.Enum = enum
|
||||
} else {
|
||||
insertEnum(child, path[1:], enum)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTypeTree 按名称稳定输出嵌套消息和枚举。
|
||||
func writeTypeTree(node *TypeNode, sb *strings.Builder, resolver *TypeResolver, indent int, currentPkg string) {
|
||||
// 对子节点排序以保证输出稳定。
|
||||
var names []string
|
||||
for name := range node.Children {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
indentStr := strings.Repeat(" ", indent)
|
||||
|
||||
for _, name := range names {
|
||||
child := node.Children[name]
|
||||
|
||||
if child.Enum != nil {
|
||||
// 检查枚举是否来自其他包。
|
||||
originalType := ""
|
||||
if copied := copiedTypes[currentPkg]; copied != nil {
|
||||
if orig, ok := copied[child.Enum.ShortName]; ok {
|
||||
originalType = orig
|
||||
}
|
||||
}
|
||||
|
||||
// 写入枚举来源注释。
|
||||
if originalType != "" {
|
||||
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, child.Enum.VarName))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Enum.TypeName, child.Enum.VarName))
|
||||
}
|
||||
// 写入枚举声明。
|
||||
sb.WriteString(fmt.Sprintf("%senum %s {\n", indentStr, name))
|
||||
for _, v := range child.Enum.Values {
|
||||
sb.WriteString(fmt.Sprintf("%s %s = %d;\n", indentStr, v.Name, v.No))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
|
||||
} else if child.Message != nil || len(child.Children) > 0 {
|
||||
// 写入消息来源注释。
|
||||
if child.Message != nil {
|
||||
varInfo := child.Message.VarName
|
||||
if child.Message.InternalName != "" && child.Message.InternalName != child.Message.VarName {
|
||||
varInfo = fmt.Sprintf("%s, class: %s", child.Message.VarName, child.Message.InternalName)
|
||||
}
|
||||
|
||||
// 检查消息是否来自其他包。
|
||||
originalType := ""
|
||||
if copied := copiedTypes[currentPkg]; copied != nil {
|
||||
if orig, ok := copied[child.Message.ShortName]; ok {
|
||||
originalType = orig
|
||||
}
|
||||
}
|
||||
|
||||
if originalType != "" {
|
||||
sb.WriteString(fmt.Sprintf("%s// Copied from: %s (var: %s)\n", indentStr, originalType, varInfo))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s// Source: %s (var: %s)\n", indentStr, child.Message.TypeName, varInfo))
|
||||
}
|
||||
}
|
||||
// 即使节点只承载嵌套类型,也要写入消息容器。
|
||||
sb.WriteString(fmt.Sprintf("%smessage %s {\n", indentStr, name))
|
||||
|
||||
// 先写入嵌套类型。
|
||||
writeTypeTree(child, sb, resolver, indent+1, currentPkg)
|
||||
|
||||
// 当前节点有消息声明时再写字段。
|
||||
if child.Message != nil {
|
||||
writeMessageFields(child.Message, sb, resolver, indent+1)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s}\n\n", indentStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeMessageFields 输出普通字段和 oneof 分组。
|
||||
func writeMessageFields(msg *Message, sb *strings.Builder, resolver *TypeResolver, indent int) {
|
||||
indentStr := strings.Repeat(" ", indent)
|
||||
|
||||
// 获取当前消息路径,用于解析相对嵌套类型。
|
||||
msgPath := msg.ShortName
|
||||
currentPkg := msg.Package
|
||||
preferredPkg, _ := parseTypeName(msg.TypeName)
|
||||
|
||||
// 按 oneof 分组字段。
|
||||
oneofGroups := make(map[string][]Field)
|
||||
var regularFields []Field
|
||||
|
||||
for _, f := range msg.Fields {
|
||||
if f.Oneof != "" {
|
||||
oneofGroups[f.Oneof] = append(oneofGroups[f.Oneof], f)
|
||||
} else {
|
||||
regularFields = append(regularFields, f)
|
||||
}
|
||||
}
|
||||
|
||||
// 先写普通字段。
|
||||
for _, f := range regularFields {
|
||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
|
||||
prefix := ""
|
||||
if f.Repeated {
|
||||
prefix = "repeated "
|
||||
} else if f.Opt {
|
||||
prefix = "optional "
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s%s %s = %d;\n", indentStr, prefix, fieldType, f.Name, f.No))
|
||||
}
|
||||
|
||||
// 再写 oneof 字段组。
|
||||
var oneofNames []string
|
||||
for name := range oneofGroups {
|
||||
oneofNames = append(oneofNames, name)
|
||||
}
|
||||
sort.Strings(oneofNames)
|
||||
|
||||
for _, oneofName := range oneofNames {
|
||||
fields := oneofGroups[oneofName]
|
||||
sb.WriteString(fmt.Sprintf("%soneof %s {\n", indentStr, oneofName))
|
||||
for _, f := range fields {
|
||||
fieldType := resolveFieldTypeWithPkg(f, resolver, msgPath, currentPkg, preferredPkg, msg.Pos, msg.ModuleStart)
|
||||
sb.WriteString(fmt.Sprintf("%s %s %s = %d;\n", indentStr, fieldType, f.Name, f.No))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s}\n", indentStr))
|
||||
}
|
||||
}
|
||||
|
||||
// parseTypeName 从全限定类型名拆出协议包和完整嵌套路径。
|
||||
func parseTypeName(typeName string) (pkg, shortName string) {
|
||||
// 优先匹配 xxx.vN.Rest 形式的版本化协议包。
|
||||
versionRe := regexp.MustCompile(`^([\w.]+\.v\d+)\.(.+)$`)
|
||||
if match := versionRe.FindStringSubmatch(typeName); match != nil {
|
||||
return match[1], match[2]
|
||||
}
|
||||
|
||||
// 单独处理 google.protobuf 标准类型。
|
||||
if strings.HasPrefix(typeName, "google.protobuf.") {
|
||||
rest := strings.TrimPrefix(typeName, "google.protobuf.")
|
||||
return "google.protobuf", rest
|
||||
}
|
||||
|
||||
// 单独处理 google.rpc 标准类型。
|
||||
if strings.HasPrefix(typeName, "google.rpc.") {
|
||||
rest := strings.TrimPrefix(typeName, "google.rpc.")
|
||||
return "google.rpc", rest
|
||||
}
|
||||
|
||||
// 无法识别包版本时按最后一个点回退拆分。
|
||||
parts := strings.Split(typeName, ".")
|
||||
if len(parts) > 1 {
|
||||
return strings.Join(parts[:len(parts)-1], "."), parts[len(parts)-1]
|
||||
}
|
||||
return "", typeName
|
||||
}
|
||||
|
||||
// getNestedPath 把嵌套类型名拆成逐级路径。
|
||||
func getNestedPath(shortName string) []string {
|
||||
return strings.Split(shortName, ".")
|
||||
}
|
||||
|
||||
// resolveFieldTypeWithPkg 结合当前包和父消息路径解析字段类型。
|
||||
func resolveFieldTypeWithPkg(f Field, resolver *TypeResolver, parentPath string, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int) string {
|
||||
resolveNamedType := func(ref string, expectedKind string) string {
|
||||
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, preferredPkg, expectedKind)
|
||||
if !ok {
|
||||
activeDiagnostics.addUnresolvedType(expectedKind + ":" + ref)
|
||||
return fallbackTypeToken(ref)
|
||||
}
|
||||
|
||||
refPkg, shortName := parseTypeName(typeName)
|
||||
|
||||
// 类型位于同一父消息下时使用相对路径。
|
||||
if parentPath != "" && strings.HasPrefix(shortName, parentPath+".") {
|
||||
// 例如消息内部将 ConversationMessage.CodeChunk 缩短为 CodeChunk。
|
||||
return strings.TrimPrefix(shortName, parentPath+".")
|
||||
}
|
||||
|
||||
// 同包类型只使用短名称。
|
||||
if refPkg == currentPkg || refPkg == "" {
|
||||
return shortName
|
||||
}
|
||||
|
||||
// 循环依赖中优先使用已经复制到当前包的类型。
|
||||
if copied := copiedTypes[currentPkg]; copied != nil {
|
||||
if _, isCopied := copied[shortName]; isCopied {
|
||||
// 本地存在复制类型时使用短名称。
|
||||
return shortName
|
||||
}
|
||||
}
|
||||
|
||||
// 其余跨包引用保留全限定类型名。
|
||||
return refPkg + "." + shortName
|
||||
}
|
||||
|
||||
if f.Kind == "scalar" {
|
||||
if t, ok := f.T.(int); ok {
|
||||
return scalarTypes[t]
|
||||
}
|
||||
if t, ok := f.T.(float64); ok {
|
||||
return scalarTypes[int(t)]
|
||||
}
|
||||
}
|
||||
|
||||
if f.Kind == "message" || f.Kind == "enum" {
|
||||
if ref, ok := f.T.(string); ok {
|
||||
return resolveNamedType(ref, f.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
if f.Kind == "map" {
|
||||
// map 字段分别解析键和值类型。
|
||||
keyType := scalarTypes[f.MapKey]
|
||||
if keyType == "" {
|
||||
keyType = "string" // 未知标量默认使用字符串。
|
||||
}
|
||||
|
||||
var valueType string
|
||||
if f.MapValueKind == "scalar" {
|
||||
if t, ok := f.MapValueT.(int); ok {
|
||||
valueType = scalarTypes[t]
|
||||
} else if t, ok := f.MapValueT.(float64); ok {
|
||||
valueType = scalarTypes[int(t)]
|
||||
}
|
||||
} else if f.MapValueKind == "message" || f.MapValueKind == "enum" {
|
||||
if ref, ok := f.MapValueT.(string); ok {
|
||||
valueType = resolveNamedType(ref, f.MapValueKind)
|
||||
}
|
||||
}
|
||||
if valueType == "" {
|
||||
valueType = "bytes"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("map<%s, %s>", keyType, valueType)
|
||||
}
|
||||
|
||||
return "bytes" // 未识别字段类型时回退为字节串。
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
// resolver.go 解析压缩 bundle 中的局部符号、模块别名和导出别名。
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// newTypeResolver 建立消息、枚举和模块别名的统一索引。
|
||||
func newTypeResolver(messages []Message, enums []Enum, aliases aliasIndex, exportAliases aliasIndex) *TypeResolver {
|
||||
resolver := &TypeResolver{
|
||||
bySymbol: make(map[string][]symbolDef),
|
||||
byAlias: make(map[string][]symbolDef),
|
||||
byShort: make(map[string][]symbolDef),
|
||||
}
|
||||
|
||||
add := func(symbol, typeName string, pos int, moduleStart int, kind string) {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
typeName = strings.TrimSpace(typeName)
|
||||
if symbol == "" || typeName == "" {
|
||||
return
|
||||
}
|
||||
def := symbolDef{TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind}
|
||||
resolver.bySymbol[symbol] = append(resolver.bySymbol[symbol], def)
|
||||
_, shortName := parseTypeName(typeName)
|
||||
if shortName != "" {
|
||||
resolver.byShort[shortName] = append(resolver.byShort[shortName], def)
|
||||
underscoreAlias := strings.ReplaceAll(shortName, ".", "_")
|
||||
if underscoreAlias != shortName {
|
||||
resolver.byShort[underscoreAlias] = append(resolver.byShort[underscoreAlias], def)
|
||||
}
|
||||
if idx := strings.LastIndex(shortName, "."); idx > 0 && idx+1 < len(shortName) {
|
||||
resolver.byShort[shortName[idx+1:]] = append(resolver.byShort[shortName[idx+1:]], def)
|
||||
}
|
||||
if idx := strings.LastIndex(underscoreAlias, "_"); idx > 0 && idx+1 < len(underscoreAlias) {
|
||||
resolver.byShort[underscoreAlias[idx+1:]] = append(resolver.byShort[underscoreAlias[idx+1:]], def)
|
||||
}
|
||||
}
|
||||
}
|
||||
addAlias := func(symbol, typeName string, pos int, moduleStart int, kind string) {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
typeName = strings.TrimSpace(typeName)
|
||||
if symbol == "" || typeName == "" {
|
||||
return
|
||||
}
|
||||
resolver.byAlias[symbol] = append(resolver.byAlias[symbol], symbolDef{
|
||||
TypeName: typeName, Pos: pos, ModuleStart: moduleStart, Kind: kind,
|
||||
})
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
add(msg.VarName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||
if msg.InternalName != "" && msg.InternalName != msg.VarName {
|
||||
add(msg.InternalName, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||
}
|
||||
for _, alias := range aliasesForSymbols(aliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
|
||||
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||
}
|
||||
}
|
||||
for _, enum := range enums {
|
||||
add(enum.VarName, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||
for _, alias := range aliasesForSymbols(aliases[enum.ModuleStart], enum.VarName) {
|
||||
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||
}
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
for _, alias := range aliasesForSymbols(exportAliases[msg.ModuleStart], msg.VarName, msg.InternalName) {
|
||||
addAlias(alias, msg.TypeName, msg.Pos, msg.ModuleStart, "message")
|
||||
}
|
||||
}
|
||||
for _, enum := range enums {
|
||||
for _, alias := range aliasesForSymbols(exportAliases[enum.ModuleStart], enum.VarName) {
|
||||
addAlias(alias, enum.TypeName, enum.Pos, enum.ModuleStart, "enum")
|
||||
}
|
||||
}
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// buildAliasIndex 提取变量声明和赋值形成的局部别名。
|
||||
func buildAliasIndex(text string, moduleStarts []int) aliasIndex {
|
||||
directByModule := make(map[int]map[string]string)
|
||||
addMatches := func(matches [][]int) {
|
||||
for _, match := range matches {
|
||||
alias := strings.TrimSpace(text[match[2]:match[3]])
|
||||
target := strings.TrimSpace(text[match[4]:match[5]])
|
||||
if alias == "" || target == "" || alias == target {
|
||||
continue
|
||||
}
|
||||
moduleStart := moduleStartForPos(moduleStarts, match[0])
|
||||
if directByModule[moduleStart] == nil {
|
||||
directByModule[moduleStart] = make(map[string]string)
|
||||
}
|
||||
directByModule[moduleStart][alias] = target
|
||||
}
|
||||
}
|
||||
|
||||
addMatches(varAliasRe.FindAllStringSubmatchIndex(text, -1))
|
||||
addMatches(assignmentAliasRe.FindAllStringSubmatchIndex(text, -1))
|
||||
|
||||
resolveRoot := func(direct map[string]string, symbol string) string {
|
||||
seen := make(map[string]bool)
|
||||
current := symbol
|
||||
for {
|
||||
if seen[current] {
|
||||
return symbol
|
||||
}
|
||||
seen[current] = true
|
||||
next := direct[current]
|
||||
if next == "" {
|
||||
return current
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
aliasSets := make(map[int]map[string]map[string]bool)
|
||||
addAlias := func(moduleStart int, root string, alias string) {
|
||||
root = strings.TrimSpace(root)
|
||||
alias = strings.TrimSpace(alias)
|
||||
if root == "" || alias == "" || root == alias {
|
||||
return
|
||||
}
|
||||
if aliasSets[moduleStart] == nil {
|
||||
aliasSets[moduleStart] = make(map[string]map[string]bool)
|
||||
}
|
||||
if aliasSets[moduleStart][root] == nil {
|
||||
aliasSets[moduleStart][root] = make(map[string]bool)
|
||||
}
|
||||
aliasSets[moduleStart][root][alias] = true
|
||||
}
|
||||
|
||||
for moduleStart, direct := range directByModule {
|
||||
for alias := range direct {
|
||||
root := resolveRoot(direct, alias)
|
||||
addAlias(moduleStart, root, alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliasSets) == 0 {
|
||||
return nil
|
||||
}
|
||||
aliases := make(aliasIndex, len(aliasSets))
|
||||
for moduleStart, roots := range aliasSets {
|
||||
aliases[moduleStart] = make(map[string][]string, len(roots))
|
||||
for root, set := range roots {
|
||||
for alias := range set {
|
||||
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
|
||||
}
|
||||
sort.Strings(aliases[moduleStart][root])
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// buildWebpackExportAliasIndex 提取 Webpack 导出表中的符号别名。
|
||||
func buildWebpackExportAliasIndex(text string, moduleStarts []int) aliasIndex {
|
||||
aliasSets := make(map[int]map[string]map[string]bool)
|
||||
addAlias := func(moduleStart int, root string, alias string) {
|
||||
root = strings.TrimSpace(root)
|
||||
alias = strings.TrimSpace(alias)
|
||||
if root == "" || alias == "" || root == alias {
|
||||
return
|
||||
}
|
||||
if aliasSets[moduleStart] == nil {
|
||||
aliasSets[moduleStart] = make(map[string]map[string]bool)
|
||||
}
|
||||
if aliasSets[moduleStart][root] == nil {
|
||||
aliasSets[moduleStart][root] = make(map[string]bool)
|
||||
}
|
||||
aliasSets[moduleStart][root][alias] = true
|
||||
}
|
||||
|
||||
// Webpack 通过 n.d(t, { KS: () => T }) 暴露成员;服务使用 r.KS,消息定义使用局部符号 T。
|
||||
for _, blockMatch := range webpackExportBlockRe.FindAllStringIndex(text, -1) {
|
||||
moduleStart := moduleStartForPos(moduleStarts, blockMatch[0])
|
||||
blockStart := blockMatch[1] - 1
|
||||
blockEnd := findMatchingBrace(text, blockStart)
|
||||
if blockEnd == -1 {
|
||||
continue
|
||||
}
|
||||
block := text[blockStart:blockEnd]
|
||||
for _, entry := range webpackExportEntryRe.FindAllStringSubmatch(block, -1) {
|
||||
addAlias(moduleStart, entry[2], entry[1])
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliasSets) == 0 {
|
||||
return nil
|
||||
}
|
||||
aliases := make(aliasIndex, len(aliasSets))
|
||||
for moduleStart, roots := range aliasSets {
|
||||
aliases[moduleStart] = make(map[string][]string, len(roots))
|
||||
for root, set := range roots {
|
||||
for alias := range set {
|
||||
aliases[moduleStart][root] = append(aliases[moduleStart][root], alias)
|
||||
}
|
||||
sort.Strings(aliases[moduleStart][root])
|
||||
}
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
// aliasesForSymbols 返回目标符号集合对应的去重别名。
|
||||
func aliasesForSymbols(aliases map[string][]string, symbols ...string) []string {
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, symbol := range symbols {
|
||||
for _, alias := range aliases[strings.TrimSpace(symbol)] {
|
||||
if alias == "" || seen[alias] {
|
||||
continue
|
||||
}
|
||||
seen[alias] = true
|
||||
result = append(result, alias)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// looksLikeFullTypeName 判断引用是否已经是全限定协议类型名。
|
||||
func looksLikeFullTypeName(ref string) bool {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
if strings.HasPrefix(trimmed, "google.protobuf.") || strings.HasPrefix(trimmed, "google.rpc.") {
|
||||
return true
|
||||
}
|
||||
matched, _ := regexp.MatchString(`^[\w.]+\.v\d+\.[\w.]+$`, trimmed)
|
||||
return matched
|
||||
}
|
||||
|
||||
// pickBestDefinition 按模块、类别、首选包和源码距离选择定义。
|
||||
func pickBestDefinition(candidates []symbolDef, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (symbolDef, bool) {
|
||||
if len(candidates) == 0 {
|
||||
return symbolDef{}, false
|
||||
}
|
||||
|
||||
filtered := candidates
|
||||
if strings.TrimSpace(expectedKind) != "" {
|
||||
tmp := make([]symbolDef, 0, len(candidates))
|
||||
for _, item := range candidates {
|
||||
if item.Kind == expectedKind {
|
||||
tmp = append(tmp, item)
|
||||
}
|
||||
}
|
||||
if len(tmp) > 0 {
|
||||
filtered = tmp
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(preferredPkg) != "" {
|
||||
tmp := make([]symbolDef, 0, len(filtered))
|
||||
for _, item := range filtered {
|
||||
pkg, _ := parseTypeName(item.TypeName)
|
||||
if pkg == preferredPkg {
|
||||
tmp = append(tmp, item)
|
||||
}
|
||||
}
|
||||
if len(tmp) > 0 {
|
||||
filtered = tmp
|
||||
}
|
||||
}
|
||||
|
||||
if contextModuleStart > 0 {
|
||||
tmp := make([]symbolDef, 0, len(filtered))
|
||||
for _, item := range filtered {
|
||||
if item.ModuleStart == contextModuleStart {
|
||||
tmp = append(tmp, item)
|
||||
}
|
||||
}
|
||||
if len(tmp) > 0 {
|
||||
filtered = tmp
|
||||
}
|
||||
}
|
||||
|
||||
// 选择绝对距离最近的定义,距离相同时优先前向定义。
|
||||
bestIndex := -1
|
||||
bestDistance := 0
|
||||
bestIsFuture := false
|
||||
for index, item := range filtered {
|
||||
distance := absInt(item.Pos - contextPos)
|
||||
isFuture := item.Pos > contextPos
|
||||
if bestIndex == -1 {
|
||||
bestIndex = index
|
||||
bestDistance = distance
|
||||
bestIsFuture = isFuture
|
||||
continue
|
||||
}
|
||||
if distance < bestDistance {
|
||||
bestIndex = index
|
||||
bestDistance = distance
|
||||
bestIsFuture = isFuture
|
||||
continue
|
||||
}
|
||||
if distance == bestDistance {
|
||||
// 距离相同时优先当前位置之前的定义。
|
||||
if bestIsFuture && !isFuture {
|
||||
bestIndex = index
|
||||
bestIsFuture = isFuture
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestIndex < 0 {
|
||||
return symbolDef{}, false
|
||||
}
|
||||
return filtered[bestIndex], true
|
||||
}
|
||||
|
||||
// ResolveTypeName 把局部变量、别名或短名称解析为全限定类型名。
|
||||
func (resolver *TypeResolver) ResolveTypeName(ref string, contextPos int, contextModuleStart int, preferredPkg string, expectedKind string) (string, bool) {
|
||||
if resolver == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
if looksLikeFullTypeName(trimmed) {
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
resolveBySymbol := func(symbol string, preferSameModule bool) (string, bool) {
|
||||
candidates := resolver.bySymbol[symbol]
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
moduleStart := 0
|
||||
if preferSameModule {
|
||||
moduleStart = contextModuleStart
|
||||
}
|
||||
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return best.TypeName, true
|
||||
}
|
||||
resolveByAlias := func(symbol string, targetModuleStart int) (string, bool) {
|
||||
candidates := resolver.byAlias[symbol]
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
best, ok := pickBestDefinition(candidates, contextPos, targetModuleStart, preferredPkg, expectedKind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return best.TypeName, true
|
||||
}
|
||||
resolveByShort := func(symbol string, preferSameModule bool) (string, bool) {
|
||||
candidates := resolver.byShort[symbol]
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
moduleStart := 0
|
||||
if preferSameModule {
|
||||
moduleStart = contextModuleStart
|
||||
}
|
||||
best, ok := pickBestDefinition(candidates, contextPos, moduleStart, preferredPkg, expectedKind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return best.TypeName, true
|
||||
}
|
||||
|
||||
if typeName, ok := resolveBySymbol(trimmed, !strings.Contains(trimmed, ".")); ok {
|
||||
return typeName, true
|
||||
}
|
||||
if typeName, ok := resolveByAlias(trimmed, 0); ok {
|
||||
return typeName, true
|
||||
}
|
||||
if typeName, ok := resolveByShort(trimmed, !strings.Contains(trimmed, ".")); ok {
|
||||
return typeName, true
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, ".") {
|
||||
parts := strings.Split(trimmed, ".")
|
||||
first := parts[0]
|
||||
last := parts[len(parts)-1]
|
||||
targetModuleStart := 0
|
||||
if imports := resolver.moduleImports[contextModuleStart]; imports != nil {
|
||||
targetModuleStart = imports[first]
|
||||
}
|
||||
if typeName, ok := resolveByAlias(last, targetModuleStart); ok {
|
||||
return typeName, true
|
||||
}
|
||||
if typeName, ok := resolveBySymbol(last, false); ok {
|
||||
return typeName, true
|
||||
}
|
||||
if typeName, ok := resolveByShort(last, false); ok {
|
||||
return typeName, true
|
||||
}
|
||||
if typeName, ok := resolveBySymbol(first, false); ok {
|
||||
return typeName, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// fallbackTypeToken 从无法解析的引用生成合法类型占位名。
|
||||
func fallbackTypeToken(ref string) string {
|
||||
token := strings.TrimSpace(ref)
|
||||
if token == "" {
|
||||
return token
|
||||
}
|
||||
if strings.Contains(token, ".") {
|
||||
parts := strings.Split(token, ".")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// absInt 返回整数绝对值。
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// services.go 解析枚举、服务方法和压缩对象的配对括号。
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// extractEnums 从旧式和工厂式声明中提取枚举。
|
||||
func extractEnums(text string, moduleStarts []int) []Enum {
|
||||
var enums []Enum
|
||||
enumExists := func(typeName, varName string) bool {
|
||||
for _, existing := range enums {
|
||||
if existing.TypeName == typeName && existing.VarName == varName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 匹配任意包中的 setEnumType(XXX, "xxx.v1.EnumName", [...]) 枚举声明。
|
||||
// JS 变量名可以包含 $ 符号
|
||||
enumRe := regexp.MustCompile(`setEnumType\s*\(\s*([\w$]+)\s*,\s*"([\w.]+)"\s*,\s*\[`)
|
||||
|
||||
matches := enumRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, match := range matches {
|
||||
varName := text[match[2]:match[3]]
|
||||
typeName := text[match[4]:match[5]]
|
||||
|
||||
// 提取枚举值数组。
|
||||
bracketStart := match[1] - 1
|
||||
values := extractEnumValues(text, bracketStart)
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
enum := Enum{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Values: values,
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: match[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
|
||||
}
|
||||
enums = append(enums, enum)
|
||||
}
|
||||
|
||||
// 匹配现代 @bufbuild/protobuf 工厂形式,例如 Role=A.makeEnum("aiserver.v1.InferenceMessageRole",[{...}])。
|
||||
enumFactoryRe := regexp.MustCompile(`([\w$]+)\s*=\s*[\w$.]+\.makeEnum\s*\(\s*["']([\w.]+)["']\s*,\s*\[`)
|
||||
factoryMatches := enumFactoryRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, match := range factoryMatches {
|
||||
varName := text[match[2]:match[3]]
|
||||
typeName := text[match[4]:match[5]]
|
||||
if enumExists(typeName, varName) {
|
||||
continue
|
||||
}
|
||||
|
||||
bracketStart := match[1] - 1
|
||||
if bracketStart < 0 || bracketStart >= len(text) || text[bracketStart] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
enums = append(enums, Enum{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Values: extractEnumValues(text, bracketStart),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: match[0],
|
||||
ModuleStart: moduleStartForPos(moduleStarts, match[0]),
|
||||
})
|
||||
}
|
||||
|
||||
return enums
|
||||
}
|
||||
|
||||
// extractServices 从命名或匿名描述符中提取服务。
|
||||
func extractServices(text string, moduleStarts []int) []Service {
|
||||
var services []Service
|
||||
seenTypeNames := make(map[string]bool)
|
||||
appendService := func(varName, typeName string, pos, methodsStart int) {
|
||||
if seenTypeNames[typeName] {
|
||||
return
|
||||
}
|
||||
methodsEnd := findMatchingBrace(text, methodsStart)
|
||||
if methodsEnd == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
pkg, shortName := parseTypeName(typeName)
|
||||
services = append(services, Service{
|
||||
TypeName: typeName,
|
||||
VarName: varName,
|
||||
Methods: extractMethods(text[methodsStart:methodsEnd]),
|
||||
Package: pkg,
|
||||
ShortName: shortName,
|
||||
Pos: pos,
|
||||
ModuleStart: moduleStartForPos(moduleStarts, pos),
|
||||
})
|
||||
seenTypeNames[typeName] = true
|
||||
}
|
||||
|
||||
// 匹配 VarName = { typeName: "xxx.v1.ServiceName", methods: { ... } } 服务对象。
|
||||
serviceRe := regexp.MustCompile(`([\w$]+)\s*=\s*\{\s*typeName:\s*"([\w.]+)"\s*,\s*methods:\s*\{`)
|
||||
|
||||
matches := serviceRe.FindAllStringSubmatchIndex(text, -1)
|
||||
for _, match := range matches {
|
||||
varName := text[match[2]:match[3]]
|
||||
typeName := text[match[4]:match[5]]
|
||||
|
||||
appendService(varName, typeName, match[0], match[1]-1)
|
||||
}
|
||||
|
||||
// 部分 bundle 把服务描述符直接放入数组,不预先赋给变量。
|
||||
anonymousServiceRe := regexp.MustCompile(`\{\s*typeName:\s*["']([\w.]+)["']\s*,\s*methods:\s*\{`)
|
||||
for _, match := range anonymousServiceRe.FindAllStringSubmatchIndex(text, -1) {
|
||||
typeName := text[match[2]:match[3]]
|
||||
appendService("", typeName, match[0], match[1]-1)
|
||||
}
|
||||
|
||||
return services
|
||||
}
|
||||
|
||||
// extractMethods 解析服务对象中的 RPC 方法列表。
|
||||
func extractMethods(methodsText string) []Method {
|
||||
var methods []Method
|
||||
|
||||
// 匹配包含方法名、输入、输出和调用类型的方法对象。
|
||||
methodRe := regexp.MustCompile(`\w+:\s*\{\s*name:\s*"([^"]+)"\s*,\s*I:\s*([\w$.]+)\s*,\s*O:\s*([\w$.]+)\s*,\s*kind:\s*[\w$.]+\.(Unary|ServerStreaming|ClientStreaming|BiDiStreaming)`)
|
||||
|
||||
matches := methodRe.FindAllStringSubmatch(methodsText, -1)
|
||||
for _, m := range matches {
|
||||
method := Method{
|
||||
Name: m[1],
|
||||
InputType: m[2],
|
||||
OutputType: m[3],
|
||||
Kind: m[4],
|
||||
}
|
||||
methods = append(methods, method)
|
||||
}
|
||||
|
||||
return methods
|
||||
}
|
||||
|
||||
// findMatchingBrace 查找花括号块的结束位置。
|
||||
func findMatchingBrace(text string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(text); i++ {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// extractEnumValues 从数组起点解析枚举值。
|
||||
func extractEnumValues(text string, start int) []EnumValue {
|
||||
// 查找数组的配对结束括号。
|
||||
depth := 0
|
||||
end := start
|
||||
for i := start; i < len(text); i++ {
|
||||
if text[i] == '[' {
|
||||
depth++
|
||||
} else if text[i] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrayText := text[start:end]
|
||||
|
||||
var values []EnumValue
|
||||
valueRe := regexp.MustCompile(`\{\s*no:\s*(\d+)\s*,\s*name:\s*"([^"]+)"`)
|
||||
|
||||
matches := valueRe.FindAllStringSubmatch(arrayText, -1)
|
||||
for _, m := range matches {
|
||||
no, _ := strconv.Atoi(m[1])
|
||||
values = append(values, EnumValue{No: no, Name: m[2]})
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
// types.go 定义协议提取器的领域结构、诊断状态和基础类型映射。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isGooglePkg 判断是否为无需重复生成的 Google 标准包。
|
||||
func isGooglePkg(pkg string) bool {
|
||||
return pkg == "google.protobuf" || pkg == "google.rpc"
|
||||
}
|
||||
|
||||
// scalarTypes 把运行时标量编号映射为 proto 类型。
|
||||
var scalarTypes = map[int]string{
|
||||
1: "double",
|
||||
2: "float",
|
||||
3: "int64",
|
||||
4: "uint64",
|
||||
5: "int32",
|
||||
6: "fixed64",
|
||||
7: "fixed32",
|
||||
8: "bool",
|
||||
9: "string",
|
||||
12: "bytes",
|
||||
13: "uint32",
|
||||
15: "sfixed32",
|
||||
16: "sfixed64",
|
||||
17: "sint32",
|
||||
18: "sint64",
|
||||
}
|
||||
|
||||
// strictExtractionValidation 控制校验失败是否终止提取。
|
||||
var strictExtractionValidation = true
|
||||
|
||||
// extractionDiagnostics 汇总字段解析和类型解析诊断。
|
||||
type extractionDiagnostics struct {
|
||||
totalFieldObjects int
|
||||
parsedFieldObjects int
|
||||
skippedFieldObjects int
|
||||
skippedFieldSamples []string
|
||||
unresolvedTypeRefs map[string]int
|
||||
emptyMessages []string
|
||||
placeholderHits []string
|
||||
declaredTypes int
|
||||
extractedTypes int
|
||||
missingDeclarations []string
|
||||
}
|
||||
|
||||
// newExtractionDiagnostics 创建一次提取任务的诊断容器。
|
||||
func newExtractionDiagnostics() *extractionDiagnostics {
|
||||
return &extractionDiagnostics{
|
||||
unresolvedTypeRefs: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// addSkippedField 记录未能解析的字段样本和原因。
|
||||
func (d *extractionDiagnostics) addSkippedField(fieldObject string, reason error) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.totalFieldObjects++
|
||||
d.skippedFieldObjects++
|
||||
if len(d.skippedFieldSamples) < 20 {
|
||||
trimmed := strings.TrimSpace(fieldObject)
|
||||
if len(trimmed) > 140 {
|
||||
trimmed = trimmed[:140] + "..."
|
||||
}
|
||||
if reason != nil {
|
||||
d.skippedFieldSamples = append(d.skippedFieldSamples, fmt.Sprintf("%s | %s", reason.Error(), trimmed))
|
||||
} else {
|
||||
d.skippedFieldSamples = append(d.skippedFieldSamples, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addParsedField 累计成功解析的字段数量。
|
||||
func (d *extractionDiagnostics) addParsedField() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
d.totalFieldObjects++
|
||||
d.parsedFieldObjects++
|
||||
}
|
||||
|
||||
// addUnresolvedType 按引用名称累计类型解析失败次数。
|
||||
func (d *extractionDiagnostics) addUnresolvedType(ref string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(ref)
|
||||
if key == "" {
|
||||
key = "<empty>"
|
||||
}
|
||||
d.unresolvedTypeRefs[key]++
|
||||
}
|
||||
|
||||
// SetStrictMode 设置校验失败是否终止提取。
|
||||
func SetStrictMode(enabled bool) {
|
||||
strictExtractionValidation = enabled
|
||||
}
|
||||
|
||||
// activeDiagnostics 指向当前提取任务的诊断状态。
|
||||
var activeDiagnostics *extractionDiagnostics
|
||||
|
||||
// 字段解析正则覆盖压缩 bundle 的各类声明形式。
|
||||
var (
|
||||
noRe = regexp.MustCompile(`(?:^|[,{]\s*)no:\s*(\d+)`)
|
||||
nameRe = regexp.MustCompile(`(?:^|[,{]\s*)name:\s*["']([^"']+)["']`)
|
||||
kindRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["']([^"']+)["']`)
|
||||
enumTypeRe = regexp.MustCompile(`[,\s]T:\s*[\w$.]+\.getEnumType\s*\(\s*([\w$.]+)\s*\)`)
|
||||
tRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
||||
oneofRe = regexp.MustCompile(`oneof:\s*["']([^"']+)["']`)
|
||||
repeatedRe = regexp.MustCompile(`repeated:\s*(!0|true)`)
|
||||
optRe = regexp.MustCompile(`opt:\s*(!0|true)`)
|
||||
keyRe = regexp.MustCompile(`[,\s]K:\s*(\d+)`)
|
||||
mapValueRe = regexp.MustCompile(`V:\s*\{([^}]*)\}`)
|
||||
mapValueKRe = regexp.MustCompile(`(?:^|[,{]\s*)kind:\s*["'](\w+)["']`)
|
||||
mapValueTRe = regexp.MustCompile(`[,\s]T:\s*([\w$.]+)`)
|
||||
shorthandTRe = regexp.MustCompile(`(?:^|[,\{])\s*T\s*(?:[,\}])`)
|
||||
oneofNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
fieldNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
placeholderRe = regexp.MustCompile(`^\s*(optional\s+|repeated\s+)?[A-Za-z_][A-Za-z0-9_.<>]*\s+(field_\d+|unknown(?:_[A-Za-z0-9_]+)?)\s*=\s*\d+\s*;`)
|
||||
varAliasRe = regexp.MustCompile(`\b(?:let|const|var)\s+([\w$]+)\s*=\s*([\w$]+)\s*(?:[,;])`)
|
||||
assignmentAliasRe = regexp.MustCompile(`(?:^|[;,({])\s*([\w$]+)\s*=\s*([\w$]+)\s*([,;}])`)
|
||||
webpackExportBlockRe = regexp.MustCompile(`[\w$]+\.d\(\s*[\w$]+\s*,\s*\{`)
|
||||
webpackExportEntryRe = regexp.MustCompile(`(?:^|[,\{])\s*([\w$]+)\s*:\s*\(\s*\)\s*=>\s*([\w$]+)`)
|
||||
moduleImportRe = regexp.MustCompile(`(?:\b(?:var|let|const)\s+|,)\s*([\w$]+)\s*=\s*[\w$]+\(\s*(\d+)\s*\)`)
|
||||
typeNameDeclarationRe = regexp.MustCompile(`(?:\bthis|[\w$]+)\.typeName\s*=\s*["']([\w.]+)["']`)
|
||||
serviceDeclarationRe = regexp.MustCompile(`\{\s*typeName\s*:\s*["']([\w.]+)["']\s*,\s*methods\s*:`)
|
||||
messageDeclarationRe = regexp.MustCompile(`\.makeMessageType\s*\(\s*["']([\w.]+)["']`)
|
||||
enumDeclarationRe = regexp.MustCompile(`\.makeEnum\s*\(\s*["']([\w.]+)["']`)
|
||||
legacyEnumDeclarationRe = regexp.MustCompile(`\.setEnumType\s*\(\s*[\w$]+\s*,\s*["']([\w.]+)["']`)
|
||||
streamCloseRe = regexp.MustCompile(`(?s)message\s+ExecClientControlMessage\s*\{.*?ExecClientStreamClose\s+stream_close\s*=\s*1\s*;`)
|
||||
shellStdoutRe = regexp.MustCompile(`(?s)message\s+ShellStream\s*\{.*?ShellStreamStdout\s+stdout\s*=\s*1\s*;`)
|
||||
)
|
||||
|
||||
// Field 描述一个待渲染的 protobuf 字段。
|
||||
type Field struct {
|
||||
// No 是字段编号。
|
||||
No int `json:"no"`
|
||||
// Name 是字段名称。
|
||||
Name string `json:"name"`
|
||||
// Kind 是标量、消息、枚举或映射类别。
|
||||
Kind string `json:"kind"`
|
||||
// T 保存标量编号或消息引用变量。
|
||||
T any `json:"T"`
|
||||
// Oneof 是字段所属的互斥分组。
|
||||
Oneof string `json:"oneof"`
|
||||
// Repeated 表示字段可以重复。
|
||||
Repeated bool `json:"repeated"`
|
||||
// Opt 表示字段为显式可选。
|
||||
Opt bool `json:"opt"`
|
||||
// MapKey 是映射键的标量编号。
|
||||
MapKey int `json:"K"`
|
||||
// MapValueKind 是映射值的标量或消息类别。
|
||||
MapValueKind string
|
||||
// MapValueT 保存映射值的标量编号或消息引用。
|
||||
MapValueT any
|
||||
}
|
||||
|
||||
// Message 描述提取出的消息及其源码位置。
|
||||
type Message struct {
|
||||
// TypeName 是消息的全限定类型名。
|
||||
TypeName string
|
||||
// VarName 是 JS 外部变量名。
|
||||
VarName string
|
||||
// InternalName 是 JS 内部类名。
|
||||
InternalName string
|
||||
// Fields 是消息字段列表。
|
||||
Fields []Field
|
||||
// Package 是消息所属协议包。
|
||||
Package string
|
||||
// ShortName 是包内嵌套类型名。
|
||||
ShortName string
|
||||
// Pos 是消息在 bundle 中的字节位置。
|
||||
Pos int
|
||||
// ModuleStart 是消息所在模块的起始位置。
|
||||
ModuleStart int
|
||||
}
|
||||
|
||||
// Enum 描述提取出的枚举及其源码位置。
|
||||
type Enum struct {
|
||||
// TypeName 是枚举的全限定类型名。
|
||||
TypeName string
|
||||
// VarName 是枚举对应的 JS 变量名。
|
||||
VarName string
|
||||
// Values 是枚举值列表。
|
||||
Values []EnumValue
|
||||
// Package 是枚举所属协议包。
|
||||
Package string
|
||||
// ShortName 是包内嵌套类型名。
|
||||
ShortName string
|
||||
// Pos 是枚举在 bundle 中的字节位置。
|
||||
Pos int
|
||||
// ModuleStart 是枚举所在模块的起始位置。
|
||||
ModuleStart int
|
||||
}
|
||||
|
||||
// EnumValue 描述单个枚举编号和名称。
|
||||
type EnumValue struct {
|
||||
// No 是枚举编号。
|
||||
No int
|
||||
// Name 是枚举名称。
|
||||
Name string
|
||||
}
|
||||
|
||||
// Service 描述提取出的服务及其源码位置。
|
||||
type Service struct {
|
||||
// TypeName 是服务的全限定类型名。
|
||||
TypeName string
|
||||
// VarName 是服务对应的 JS 变量名。
|
||||
VarName string
|
||||
// Methods 是服务方法列表。
|
||||
Methods []Method
|
||||
// Package 是服务所属协议包。
|
||||
Package string
|
||||
// ShortName 是服务包内名称。
|
||||
ShortName string
|
||||
// Pos 是服务在 bundle 中的字节位置。
|
||||
Pos int
|
||||
// ModuleStart 是服务所在模块的起始位置。
|
||||
ModuleStart int
|
||||
}
|
||||
|
||||
// Method 描述一个 RPC 方法的输入、输出和流模式。
|
||||
type Method struct {
|
||||
// Name 是 RPC 方法名。
|
||||
Name string
|
||||
// InputType 是输入消息引用变量。
|
||||
InputType string
|
||||
// OutputType 是输出消息引用变量。
|
||||
OutputType string
|
||||
// Kind 是一元或不同方向的流式调用类型。
|
||||
Kind string
|
||||
}
|
||||
|
||||
// symbolDef 保存符号对应的类型、类别和模块位置。
|
||||
type symbolDef struct {
|
||||
// TypeName 是符号对应的全限定类型名。
|
||||
TypeName string
|
||||
// Pos 是符号定义位置。
|
||||
Pos int
|
||||
// Kind 是消息或枚举类别。
|
||||
Kind string
|
||||
// ModuleStart 是符号所在模块起点。
|
||||
ModuleStart int
|
||||
}
|
||||
|
||||
// TypeResolver 通过局部符号、别名和短名称解析协议类型。
|
||||
type TypeResolver struct {
|
||||
bySymbol map[string][]symbolDef
|
||||
byAlias map[string][]symbolDef
|
||||
byShort map[string][]symbolDef
|
||||
moduleImports map[int]map[string]int
|
||||
}
|
||||
|
||||
// aliasIndex 按模块和目标符号保存别名集合。
|
||||
type aliasIndex map[int]map[string][]string
|
||||
@@ -1,15 +0,0 @@
|
||||
module github.com/leookun/cursor-byok/cursor-proto
|
||||
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/jhump/protoreflect v1.18.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jhump/protoreflect v1.18.0 h1:TOz0MSR/0JOZ5kECB/0ufGnC2jdsgZ123Rd/k4Z5/2w=
|
||||
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
|
||||
github.com/jhump/protoreflect/v2 v2.0.0-beta.1 h1:Dw1rslK/VotaUGYsv53XVWITr+5RCPXfvvlGrM/+B6w=
|
||||
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# extract.sh 从 Cursor 安装目录安全提取 Proto 文件。
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
INSTALLED_CURSOR_DEFAULT="/Applications/Cursor.app"
|
||||
INPUT_DEFAULT="$INSTALLED_CURSOR_DEFAULT"
|
||||
OUTPUT_DEFAULT="$PROJECT_DIR/proto"
|
||||
|
||||
INPUT_ROOT="${1:-$INPUT_DEFAULT}"
|
||||
OUTPUT_DIR="${2:-$OUTPUT_DEFAULT}"
|
||||
|
||||
canonicalize_path() {
|
||||
local path="$1"
|
||||
local parent
|
||||
local base
|
||||
if [[ -d "$path" ]]; then
|
||||
(cd "$path" && pwd -P)
|
||||
return
|
||||
fi
|
||||
parent="$(dirname "$path")"
|
||||
base="$(basename "$path")"
|
||||
if [[ ! -d "$parent" ]]; then
|
||||
echo "Parent directory does not exist: $parent" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base"
|
||||
}
|
||||
|
||||
# 文件输入只提取自身;目录输入扫描工作台、扩展宿主和扩展产物。
|
||||
INPUT_PATHS=()
|
||||
|
||||
add_input() {
|
||||
local candidate="$1"
|
||||
local existing
|
||||
if [[ ! -f "$candidate" ]]; then
|
||||
return 0
|
||||
fi
|
||||
for existing in "${INPUT_PATHS[@]-}"; do
|
||||
[[ "$existing" == "$candidate" ]] && return
|
||||
done
|
||||
INPUT_PATHS+=("$candidate")
|
||||
}
|
||||
|
||||
if [[ -f "$INPUT_ROOT" ]]; then
|
||||
add_input "$INPUT_ROOT"
|
||||
elif [[ -d "$INPUT_ROOT" ]]; then
|
||||
CANDIDATES=(
|
||||
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js"
|
||||
"$INPUT_ROOT/Resources/app/out/vs/workbench/workbench.desktop.main.js"
|
||||
"$INPUT_ROOT/out/vs/workbench/workbench.desktop.main.js"
|
||||
"$INPUT_ROOT/workbench.desktop.main.js"
|
||||
"$INPUT_ROOT/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
|
||||
"$INPUT_ROOT/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js"
|
||||
"$INPUT_ROOT/out/vs/workbench/api/node/extensionHostProcess.js"
|
||||
"$INPUT_ROOT/extensionHostProcess.js"
|
||||
"$INPUT_ROOT/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
"$INPUT_ROOT/Resources/app/extensions/cursor-always-local/dist/main.js"
|
||||
"$INPUT_ROOT/extensions/cursor-always-local/dist/main.js"
|
||||
"$INPUT_ROOT/cursor-always-local/dist/main.js"
|
||||
)
|
||||
for CANDIDATE in "${CANDIDATES[@]}"; do
|
||||
add_input "$CANDIDATE"
|
||||
done
|
||||
while IFS= read -r JS_FILE; do
|
||||
add_input "$JS_FILE"
|
||||
done < <(find "$INPUT_ROOT" -type f ! -path "*/node_modules/*" \( -name "workbench.desktop.main.js" -o -name "extensionHostProcess.js" -o -path "*/extensions/*/dist/main.js" \) | sort)
|
||||
fi
|
||||
|
||||
if [[ -z "${INPUT_PATHS[*]-}" ]]; then
|
||||
echo "No supported Cursor JS bundle found under: $INPUT_ROOT" >&2
|
||||
echo "Install/update Cursor, or pass an explicit input bundle:" >&2
|
||||
echo " $0 /path/to/Cursor.app [output-dir]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for INDEX in "${!INPUT_PATHS[@]}"; do
|
||||
INPUT_PATHS[$INDEX]="$(canonicalize_path "${INPUT_PATHS[$INDEX]}")"
|
||||
done
|
||||
OUTPUT_DIR="$(canonicalize_path "$OUTPUT_DIR")"
|
||||
CURRENT_DIR="$(pwd -P)"
|
||||
|
||||
case "$OUTPUT_DIR" in
|
||||
"/"|"$HOME"|"$PROJECT_DIR"|"$SCRIPT_DIR"|"$CURRENT_DIR")
|
||||
echo "Refusing unsafe output directory: $OUTPUT_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
|
||||
case "$INPUT_PATH" in
|
||||
"$OUTPUT_DIR"|"$OUTPUT_DIR"/*)
|
||||
echo "Refusing output directory that contains an input bundle: $OUTPUT_DIR" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
OUTPUT_PARENT="$(dirname "$OUTPUT_DIR")"
|
||||
OUTPUT_BASENAME="$(basename "$OUTPUT_DIR")"
|
||||
TEMP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.tmp.XXXXXX")"
|
||||
BACKUP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
|
||||
rm -rf "$TEMP_DIR"
|
||||
fi
|
||||
if [[ -n "$BACKUP_DIR" && -e "$BACKUP_DIR" ]]; then
|
||||
if [[ ! -e "$OUTPUT_DIR" ]]; then
|
||||
mv "$BACKUP_DIR" "$OUTPUT_DIR"
|
||||
else
|
||||
rm -rf "$BACKUP_DIR"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
EXTRACT_ARGS=()
|
||||
for INPUT_PATH in "${INPUT_PATHS[@]}"; do
|
||||
EXTRACT_ARGS+=( -input "$INPUT_PATH" )
|
||||
done
|
||||
|
||||
(
|
||||
cd "$PROJECT_DIR"
|
||||
go run ./extractor \
|
||||
"${EXTRACT_ARGS[@]}" \
|
||||
-output "$TEMP_DIR" \
|
||||
-skip-format \
|
||||
-strict
|
||||
)
|
||||
|
||||
if [[ -e "$OUTPUT_DIR" ]]; then
|
||||
BACKUP_DIR="$(mktemp -d "$OUTPUT_PARENT/.${OUTPUT_BASENAME}.backup.XXXXXX")"
|
||||
rmdir "$BACKUP_DIR"
|
||||
mv "$OUTPUT_DIR" "$BACKUP_DIR"
|
||||
fi
|
||||
mv "$TEMP_DIR" "$OUTPUT_DIR"
|
||||
TEMP_DIR=""
|
||||
if [[ -n "$BACKUP_DIR" ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
BACKUP_DIR=""
|
||||
fi
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# generate.sh 根据提取的 Proto 定义生成可供其他 Go module 使用的消息包。
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PROTO_DIR="$PROJECT_DIR/proto"
|
||||
MODULE_PATH="github.com/leookun/cursor-byok/cursor-proto"
|
||||
|
||||
command -v protoc >/dev/null 2>&1 || {
|
||||
echo "protoc is required" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v protoc-gen-go >/dev/null 2>&1 || {
|
||||
echo "protoc-gen-go is required" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for PROTO_FILE in agent_v1.proto aiserver_v1.proto; do
|
||||
if [[ ! -f "$PROTO_DIR/$PROTO_FILE" ]]; then
|
||||
echo "Missing Proto source: $PROTO_DIR/$PROTO_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
protoc \
|
||||
--proto_path="$PROTO_DIR" \
|
||||
--go_out="$PROJECT_DIR" \
|
||||
--go_opt="module=$MODULE_PATH" \
|
||||
"$PROTO_DIR/agent_v1.proto" \
|
||||
"$PROTO_DIR/aiserver_v1.proto"
|
||||
|
||||
echo "Generated Go packages under: $PROJECT_DIR/gen"
|
||||
Generated
-3053
File diff suppressed because it is too large
Load Diff
@@ -1,43 +0,0 @@
|
||||
[package]
|
||||
name = "cursor-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-stream = "0.3"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
eventsource-stream = "0.2"
|
||||
futures-util = "0.3"
|
||||
hex = "0.4"
|
||||
include_dir = "0.7"
|
||||
parking_lot = "0.12"
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
similar = "2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.6", features = ["decompression-gzip", "fs"] }
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
protoc-bin-vendored = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1"
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -1,71 +0,0 @@
|
||||
# cursor-server
|
||||
|
||||
Cursor Agent 的 Rust 服务端。它实现 `RunSSE + BidiAppend` 通信、无状态 LLM loop、客户端工具执行、Blob/KV 同步和可恢复 checkpoint。服务只接管已经实现的 Cursor 接口;其他 backend 请求原样流式转发到固定上游 `https://api2.cursor.sh`。
|
||||
|
||||
## 启动
|
||||
|
||||
首次运行需要安装 Rust stable 工具链。macOS 使用 Homebrew:
|
||||
|
||||
```bash
|
||||
brew install rustup
|
||||
export PATH="$(brew --prefix rustup)/bin:$PATH"
|
||||
rustup default stable
|
||||
cargo --version
|
||||
```
|
||||
|
||||
`rustup` 是 keg-only;若要让后续 zsh 会话也能找到 `cargo`,将下面一行加入 `~/.zshrc`,然后重新打开终端:
|
||||
|
||||
```bash
|
||||
export PATH="$(brew --prefix rustup)/bin:$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
先构建管理台:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
进入 `cursor-server` 后启动:
|
||||
|
||||
```bash
|
||||
cd ../cursor-server
|
||||
CURSOR_DATABASE_URL=sqlite://cursor-server.db \
|
||||
cargo run
|
||||
```
|
||||
|
||||
默认监听 `127.0.0.1:3000`。打开 `http://127.0.0.1:3000/console/` 配置 Provider、拉取并启用模型。Provider URL、API Key 和模型不再从环境变量隐式覆盖;SQLite 是唯一运行时配置源。Anthropic 模型必须在模型配置中设置最大输出 token,服务不会猜测默认值。完整启动环境变量见 `src/config.rs`。
|
||||
|
||||
## 不变量
|
||||
|
||||
- 不可变 messages 与 revision 父链共同构成上下文事实源;消息只追加,不原地修改,回滚只选择旧 revision 并建立新分支。
|
||||
- 每个携带用户语义的 RunRequest 生成一条 user-role/runtime-origin message;当前请求有什么上下文就加入什么,没有则省略。它使用稳定事件 ID,事务内 exactly-once 追加。
|
||||
- 同一 PromptSpec/ModelSpec/Provider route 内,每轮投射结果可复现,后一轮 messages 严格以前一轮为前缀;新 Run 切换模型或模式时只替换 Cursor system root。
|
||||
- canonical tool pairs 的 typed history 折叠属于 `model/projection.rs`;Cursor checkpoint 与各 Provider 都依赖它,彼此不反向依赖。
|
||||
- 工具每完成一个,就按实际完成顺序原子追加一组 `assistant(tool_call) → tool(result)`;投射给 LLM 的上下文没有悬空 tool call,整批完整后才继续调用 LLM。
|
||||
- Blob 是 `SHA-256(data)` 的不可变 CAS;Blob 类型来自引用字段,不编码在 BlobID 中。
|
||||
- 引用 Blob 的 checkpoint 只有在全部新 Blob 得到 KV SET ACK 后才能发布。
|
||||
- 每个新 Blob 只对应一次 KV SET 和一个配对 ACK;拒绝、超时或同步 worker 失败直接结束当前 Cursor Run,不定时制造新 id 重试。
|
||||
- checkpoint 以完整 assistant 为 staged/settled 边界:工具批次开始时 stable roots 不变、`pending_tool_calls` 内联一条完整 assistant JSON;全部工具结果提交后才进入 stable roots。最终文本 assistant 同样走 staged/settled,并在 `turn_ended` 后重发同一 settled checkpoint;staged 与 settled 复用同一个已确认 Turn,presentation delta 不得消费两次。
|
||||
- Provider 未报告 usage 时不伪造零值;`TurnEndedUpdate` 的 token 字段保持缺省。
|
||||
- 每次真实 Provider 请求对应一条 `llm_calls`;时间使用 UTC 时间点与单调时钟耗时,usage 只保存 Provider 报告值。详细模式额外保存脱敏后的最终请求和原始 SSE 字节块。
|
||||
- 用户模型公开 ID 是规范化 `URL + NUL + provider type + NUL + modelId` 的 SHA-256 前 4 bytes,表示为 8 位小写 hex;API Key 和 displayName 不参与身份。
|
||||
- `AvailableModels` 与 `GetUsableModels` 在官方响应原始 protobuf 后追加用户模型字段,不解码重编码未知字段;`requested_model.model_id` 使用公开 ID 解析 Provider 路由。
|
||||
- 新 Run 通过 conversation revision 使旧 Run 的迟到事件失效。
|
||||
- 每种工具只对应一个 Exec、Interaction 或 Local 通道;不存在级联 fallback。
|
||||
- Loop 不保存工具名称路由;`cursor/tools/dispatch/` 是 transport dispatcher,`cursor/tools/runtime.rs` 唯一拥有当前 Cursor Run 的 Exec/Interaction wire-id 和 terminal tombstone。
|
||||
- Interaction approval 不是 ToolResult;只有 typed terminal result 才能进入持久化与 checkpoint。
|
||||
- Exec 与 Interaction 共用当前 Run 唯一、单调且不复用的 wire-id 空间;typed terminal result 一次消费,大 payload 在核心 commit 后释放,完成墓碑保留到 ToolRound settled。
|
||||
- prompt 资产编译进二进制并在启动时整体校验,不与运行时目录逐文件混用。
|
||||
- `prompt/cursor/tools.json` 是 Cursor 工具 schema 唯一事实源,`prompt/cursor/modes/*.json` 只定义有序工具名或明确 variant。每个模式显式维护 `{prompt.md,runtime.md}`,不使用别名或缺失资产 fallback。
|
||||
- 当前 `UserMessage.mode` 同时选择 system prompt、runtime 模板和工具集;子代理由 `subagent_type_name` 明确选择 subagent 资产,使用无 Cloud 字段的 Task variant 并增加 `UpdateCurrentStep`。
|
||||
- `GetMcpTools` 必须等待客户端 `McpStateExecResult` 的实时 MCP 状态,不能从初始 descriptor 快照本地完成。
|
||||
- 本地精确路由优先;未匹配的 method、path/query、headers 和 body 流式转发到固定 Cursor 上游,上游 status、headers 和 body 流式返回。
|
||||
- 反向代理只改写目标 authority,并剥离不能逐跳转发的 hop-by-hop headers;不存在的本地路由不能直接返回 404。
|
||||
- `cursor/checkpoint/worker.rs` 独占可推进的 checkpoint builder;`cursor/session.rs` 只提交 staged/settled/final job 并等待相应 barrier。
|
||||
- `cursor/projection/`、`cursor/interaction/` 和 `cursor/tools/codec/` 分别按 JSON 编解码、UI 消息方向和 Exec wire 方向组织,不共享运行期状态。
|
||||
- Provider 的取消同时覆盖等待 HTTP 响应头和读取 SSE,旧 Run 不会卡在尚未建立的流上。
|
||||
- Ctrl-C/SIGTERM 先停止接受新连接并取消所有 Run/工具、关闭 RunSSE;HTTP graceful shutdown 最多等待 10 秒,随后强制释放服务。
|
||||
|
||||
模块边界和目录是实现约束,必须与仓库根目录 README 保持一致。
|
||||
@@ -1,53 +0,0 @@
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
|
||||
let proto_dir = manifest.join("../cursor-proto/proto");
|
||||
let protos = [proto_dir.join("agent_v1.proto")];
|
||||
let aiserver_proto = proto_dir.join("aiserver_v1.proto");
|
||||
|
||||
env::set_var(
|
||||
"PROTOC",
|
||||
protoc_bin_vendored::protoc_bin_path().expect("vendored protoc"),
|
||||
);
|
||||
|
||||
prost_build::Config::new()
|
||||
.compile_protos(
|
||||
&protos,
|
||||
&[
|
||||
proto_dir.clone(),
|
||||
protoc_bin_vendored::include_path().expect("vendored protobuf includes"),
|
||||
],
|
||||
)
|
||||
.expect("compile Cursor protobuf schema");
|
||||
|
||||
for proto in protos {
|
||||
println!("cargo:rerun-if-changed={}", proto.display());
|
||||
}
|
||||
let aiserver_source = std::fs::read_to_string(&aiserver_proto).expect("read aiserver_v1.proto");
|
||||
for required in [
|
||||
"message BidiAppendRequest",
|
||||
"string data = 1;",
|
||||
"BidiRequestId request_id = 2;",
|
||||
"int64 append_seqno = 3;",
|
||||
"bytes data_binary = 4;",
|
||||
"message BidiAppendResponse",
|
||||
"message CustomErrorDetails",
|
||||
"optional bool is_retryable = 4;",
|
||||
"optional bool show_request_id = 5;",
|
||||
"optional bool should_show_immediate_error = 6;",
|
||||
"message ErrorDetails",
|
||||
"ERROR_PROVIDER_ERROR = 57;",
|
||||
"CustomErrorDetails details = 2;",
|
||||
"optional bool is_expected = 3;",
|
||||
] {
|
||||
assert!(
|
||||
aiserver_source.contains(required),
|
||||
"aiserver Bidi wire schema changed: missing {required}"
|
||||
);
|
||||
}
|
||||
// The extracted aiserver file currently contains unrelated duplicate message names, so
|
||||
// compiling that entire package would generate invalid Rust. `cursor/proto.rs` defines only
|
||||
// the validated Bidi and ErrorDetails wire subsets; agent_v1.proto remains fully generated.
|
||||
println!("cargo:rerun-if-changed={}", aiserver_proto.display());
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
current_revision_id INTEGER,
|
||||
active_run_id TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
runtime_event_id TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, message_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX messages_runtime_event
|
||||
ON messages(conversation_id, runtime_event_id)
|
||||
WHERE runtime_event_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE conversation_revisions (
|
||||
revision_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
parent_revision_id INTEGER,
|
||||
state_digest BLOB NOT NULL CHECK(length(state_digest) = 32),
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
UNIQUE (conversation_id, state_digest),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (parent_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE INDEX conversation_revisions_parent
|
||||
ON conversation_revisions(conversation_id, parent_revision_id);
|
||||
|
||||
CREATE TABLE revision_messages (
|
||||
revision_id INTEGER NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
PRIMARY KEY (revision_id, ordinal),
|
||||
UNIQUE (revision_id, message_id),
|
||||
FOREIGN KEY (revision_id) REFERENCES conversation_revisions(revision_id),
|
||||
FOREIGN KEY (conversation_id, message_id) REFERENCES messages(conversation_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
head_revision_id INTEGER NOT NULL,
|
||||
parent_run_id TEXT,
|
||||
parent_tool_call_id TEXT,
|
||||
run_kind TEXT NOT NULL,
|
||||
subagent_kind TEXT,
|
||||
status TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL DEFAULT -1,
|
||||
turn_usage_json TEXT NOT NULL DEFAULT 'null',
|
||||
failure_category TEXT,
|
||||
failure_summary TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id),
|
||||
FOREIGN KEY (head_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE INDEX runs_conversation_status
|
||||
ON runs(conversation_id, status);
|
||||
|
||||
CREATE TABLE tool_rounds (
|
||||
round_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
assistant_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
next_completion_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES runs(run_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE INDEX tool_rounds_run_status
|
||||
ON tool_rounds(run_id, status);
|
||||
|
||||
CREATE TABLE tool_round_calls (
|
||||
round_id TEXT NOT NULL,
|
||||
call_index INTEGER NOT NULL,
|
||||
call_id TEXT NOT NULL,
|
||||
model_call_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
arguments_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
completion_seq INTEGER,
|
||||
result_content TEXT,
|
||||
result_is_error INTEGER,
|
||||
committed_revision_id INTEGER,
|
||||
completed_at_ms INTEGER,
|
||||
PRIMARY KEY (round_id, call_index),
|
||||
UNIQUE (round_id, call_id),
|
||||
UNIQUE (round_id, completion_seq),
|
||||
FOREIGN KEY (round_id) REFERENCES tool_rounds(round_id),
|
||||
FOREIGN KEY (committed_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE TABLE blobs (
|
||||
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
|
||||
data BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE blob_edges (
|
||||
parent_blob_id BLOB NOT NULL,
|
||||
child_blob_id BLOB NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
PRIMARY KEY (parent_blob_id, child_blob_id, field_name),
|
||||
FOREIGN KEY (parent_blob_id) REFERENCES blobs(blob_id),
|
||||
FOREIGN KEY (child_blob_id) REFERENCES blobs(blob_id)
|
||||
);
|
||||
|
||||
CREATE INDEX blob_edges_child ON blob_edges(child_blob_id);
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE input_anchors (
|
||||
conversation_id TEXT NOT NULL,
|
||||
input_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, input_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
@@ -1,103 +0,0 @@
|
||||
CREATE TABLE provider_endpoints (
|
||||
provider_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
custom_headers_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE provider_models (
|
||||
model_hash TEXT PRIMARY KEY CHECK(length(model_hash) = 8),
|
||||
provider_id INTEGER NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
context_window_tokens INTEGER,
|
||||
max_output_tokens INTEGER,
|
||||
reasoning_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_effort TEXT,
|
||||
extra_params_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
UNIQUE(provider_id, model_id),
|
||||
FOREIGN KEY(provider_id) REFERENCES provider_endpoints(provider_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX provider_models_enabled_sort
|
||||
ON provider_models(enabled, sort_order, display_name);
|
||||
|
||||
CREATE TABLE service_settings (
|
||||
setting_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO service_settings(setting_key, value_json, updated_at_ms)
|
||||
VALUES ('llm_detailed_logging', 'false', unixepoch('subsec') * 1000);
|
||||
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL,
|
||||
model_hash TEXT,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_url TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
finish_reason TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
request_started_at_ms INTEGER,
|
||||
response_headers_at_ms INTEGER,
|
||||
first_event_at_ms INTEGER,
|
||||
first_text_at_ms INTEGER,
|
||||
finished_at_ms INTEGER,
|
||||
queue_ms INTEGER,
|
||||
ttfb_ms INTEGER,
|
||||
ttft_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
usage_json TEXT,
|
||||
message_count INTEGER NOT NULL,
|
||||
tool_count INTEGER NOT NULL,
|
||||
request_bytes INTEGER,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
stream_event_count INTEGER NOT NULL DEFAULT 0,
|
||||
http_status INTEGER,
|
||||
error_kind TEXT,
|
||||
error_message TEXT,
|
||||
detailed INTEGER NOT NULL,
|
||||
FOREIGN KEY(model_hash) REFERENCES provider_models(model_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
|
||||
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
|
||||
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
|
||||
|
||||
CREATE TABLE llm_call_requests (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
headers_json TEXT NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE llm_call_response_chunks (
|
||||
call_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
received_offset_ms INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(call_id, seq),
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
use std::{future::IntoFuture, time::Duration};
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
control,
|
||||
cursor::{
|
||||
handlers,
|
||||
prompting::{PromptAssets, PromptCompiler},
|
||||
CursorSessionRegistry,
|
||||
},
|
||||
provider::ProviderRouter,
|
||||
run::RunRegistry,
|
||||
store::Store,
|
||||
Result,
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
router: axum::Router,
|
||||
registry: CursorSessionRegistry,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub async fn new(config: Config) -> Result<Self> {
|
||||
let store = Store::connect(&config.database_url).await?;
|
||||
let assets = PromptAssets::embedded()?;
|
||||
let compiler = PromptCompiler::new(assets);
|
||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||
store.clone(),
|
||||
config.provider_request_timeout,
|
||||
));
|
||||
let run_registry = RunRegistry::default();
|
||||
let registry = CursorSessionRegistry::new(store.clone(), provider, compiler, run_registry);
|
||||
let router = handlers::router(registry.clone())?.merge(control::router(store));
|
||||
Ok(Self {
|
||||
router,
|
||||
registry,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn serve(self) -> Result<()> {
|
||||
let listener = TcpListener::bind(self.config.listen_addr).await?;
|
||||
tracing::info!(address = %self.config.listen_addr, "cursor server listening");
|
||||
let registry = self.registry;
|
||||
let shutdown = CancellationToken::new();
|
||||
let graceful = shutdown.clone();
|
||||
let server = axum::serve(listener, self.router)
|
||||
.with_graceful_shutdown(async move {
|
||||
graceful.cancelled().await;
|
||||
})
|
||||
.into_future();
|
||||
tokio::pin!(server);
|
||||
|
||||
let signal = shutdown_signal(registry, shutdown);
|
||||
tokio::pin!(signal);
|
||||
tokio::select! {
|
||||
result = &mut server => result?,
|
||||
() = &mut signal => {
|
||||
match tokio::time::timeout(Duration::from_secs(10), &mut server).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => tracing::warn!("graceful shutdown timed out; forcing server close"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal(registry: CursorSessionRegistry, shutdown: CancellationToken) {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
if let Ok(mut signal) =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
{
|
||||
signal.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
||||
tracing::info!("shutdown signal received; cancelling active runs");
|
||||
shutdown.cancel();
|
||||
registry.shutdown().await;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use crate::model::RuntimeEvent;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClientCommand {
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
content: String,
|
||||
is_error: bool,
|
||||
},
|
||||
RuntimeEvent(RuntimeEvent),
|
||||
ClientClosed {
|
||||
error: String,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::model::{RevisionId, ToolCall, ToolRoundId, Usage};
|
||||
use crate::run::RunOutcome;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CommitCause {
|
||||
InitialMessages,
|
||||
ToolRoundStarted(ToolRoundId),
|
||||
ToolResult { call_id: String },
|
||||
FinalTurn,
|
||||
RuntimeEvent { event_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommitBarrier {
|
||||
None,
|
||||
BeforeContinue(oneshot::Sender<std::result::Result<(), String>>),
|
||||
}
|
||||
|
||||
impl CommitBarrier {
|
||||
pub fn before_continue() -> (Self, oneshot::Receiver<std::result::Result<(), String>>) {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
(Self::BeforeContinue(sender), receiver)
|
||||
}
|
||||
|
||||
pub fn is_required(&self) -> bool {
|
||||
matches!(self, Self::BeforeContinue(_))
|
||||
}
|
||||
|
||||
pub fn complete(self, result: std::result::Result<(), String>) {
|
||||
if let Self::BeforeContinue(sender) = self {
|
||||
let _ = sender.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StateCommitted {
|
||||
pub revision_id: RevisionId,
|
||||
pub tool_round_version: u64,
|
||||
pub cause: CommitCause,
|
||||
pub barrier: CommitBarrier,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientEvent {
|
||||
TextStart,
|
||||
TextDelta(String),
|
||||
TextEnd,
|
||||
ThinkingStart,
|
||||
ThinkingDelta(String),
|
||||
ThinkingEnd {
|
||||
duration: Duration,
|
||||
},
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
model_call_id: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
delta: String,
|
||||
},
|
||||
ToolCallEnd {
|
||||
index: usize,
|
||||
},
|
||||
Usage(Usage),
|
||||
ExecuteToolRound {
|
||||
round_id: ToolRoundId,
|
||||
calls: Vec<ToolCall>,
|
||||
},
|
||||
StateCommitted(StateCommitted),
|
||||
Ended(RunOutcome),
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod command;
|
||||
mod event;
|
||||
mod session;
|
||||
|
||||
pub use command::*;
|
||||
pub use event::*;
|
||||
pub use session::*;
|
||||
@@ -1,28 +0,0 @@
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::{ClientCommand, ClientEvent};
|
||||
|
||||
pub struct ClientPort {
|
||||
pub commands: mpsc::Receiver<ClientCommand>,
|
||||
pub events: mpsc::Sender<ClientEvent>,
|
||||
}
|
||||
|
||||
pub struct ClientSession {
|
||||
pub commands: mpsc::Sender<ClientCommand>,
|
||||
pub events: mpsc::Receiver<ClientEvent>,
|
||||
}
|
||||
|
||||
pub fn session(capacity: usize) -> (ClientPort, ClientSession) {
|
||||
let (commands_tx, commands_rx) = mpsc::channel(capacity);
|
||||
let (events_tx, events_rx) = mpsc::channel(capacity);
|
||||
(
|
||||
ClientPort {
|
||||
commands: commands_rx,
|
||||
events: events_tx,
|
||||
},
|
||||
ClientSession {
|
||||
commands: commands_tx,
|
||||
events: events_rx,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use std::{env, net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderKind {
|
||||
OpenAiChat,
|
||||
OpenAiResponses,
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderKind,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub custom_headers: reqwest::header::HeaderMap,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub database_url: String,
|
||||
pub provider_request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let listen_addr = env::var("CURSOR_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:3000".into())
|
||||
.parse()
|
||||
.map_err(|error| Error::Config(format!("invalid CURSOR_LISTEN_ADDR: {error}")))?;
|
||||
let request_timeout = match env::var("CURSOR_PROVIDER_TIMEOUT_SECONDS") {
|
||||
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
||||
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
||||
})?),
|
||||
Err(env::VarError::NotPresent) => Duration::from_secs(300),
|
||||
Err(error) => {
|
||||
return Err(Error::Config(format!(
|
||||
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
listen_addr,
|
||||
database_url: env::var("CURSOR_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://cursor-server.db".into()),
|
||||
provider_request_timeout: request_timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{model::LlmCallSummary, Result};
|
||||
|
||||
use super::{CallDetail, ControlService};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallQuery {
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i64,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(service): State<ControlService>,
|
||||
Query(query): Query<CallQuery>,
|
||||
) -> Result<Json<Vec<LlmCallSummary>>> {
|
||||
Ok(Json(service.calls(query.limit).await?))
|
||||
}
|
||||
|
||||
pub async fn detail(
|
||||
State(service): State<ControlService>,
|
||||
Path(call_id): Path<String>,
|
||||
) -> Result<Json<CallDetail>> {
|
||||
Ok(Json(service.call(&call_id).await?))
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
100
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
mod calls;
|
||||
mod models;
|
||||
mod providers;
|
||||
mod service;
|
||||
mod settings;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub use service::{CallDetail, ControlService, DiscoveredModels, ObservabilitySettings};
|
||||
|
||||
pub fn router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
|
||||
Router::new()
|
||||
.nest_service(
|
||||
"/console",
|
||||
ServeDir::new(assets).append_index_html_on_directories(true),
|
||||
)
|
||||
.route(
|
||||
"/api/providers",
|
||||
get(providers::list).post(providers::create),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{provider_id}",
|
||||
put(providers::update).delete(providers::remove),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{provider_id}/models/discover",
|
||||
post(models::discover),
|
||||
)
|
||||
.route("/api/providers/{provider_id}/models", post(models::save))
|
||||
.route("/api/models", get(models::list))
|
||||
.route("/api/models/{model_hash}", delete(models::remove))
|
||||
.route("/api/llm-calls", get(calls::list))
|
||||
.route("/api/llm-calls/{call_id}", get(calls::detail))
|
||||
.route(
|
||||
"/api/settings/observability",
|
||||
get(settings::get).put(settings::update),
|
||||
)
|
||||
.with_state(service)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{ProviderModel, ProviderModelInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{ControlService, DiscoveredModels};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveModels {
|
||||
pub models: Vec<ProviderModelInput>,
|
||||
}
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> {
|
||||
Ok(Json(service.models().await?))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<SaveModels>,
|
||||
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.save_models(provider_id, &input.models).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(model_hash): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
service.delete_model(&model_hash).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn discover(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
) -> Result<Json<DiscoveredModels>> {
|
||||
Ok(Json(service.discover_models(provider_id).await?))
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{ProviderEndpoint, ProviderEndpointInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
|
||||
Ok(Json(service.providers().await?))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.create_provider(&input).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<Json<ProviderEndpoint>> {
|
||||
Ok(Json(
|
||||
service.update_provider(provider_id, &input).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
service.delete_provider(provider_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
LlmCallRequest, LlmCallResponseChunk, LlmCallSummary, ProviderEndpoint,
|
||||
ProviderEndpointInput, ProviderEndpointSecret, ProviderModel, ProviderModelInput,
|
||||
ProviderType,
|
||||
},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlService {
|
||||
store: Store,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DiscoveredModels {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CallDetail {
|
||||
pub call: LlmCallSummary,
|
||||
pub request: Option<LlmCallRequest>,
|
||||
pub response_chunks: Vec<LlmCallResponseChunk>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
pub struct ObservabilitySettings {
|
||||
pub detailed: bool,
|
||||
}
|
||||
|
||||
impl ControlService {
|
||||
pub fn new(store: Store) -> Self {
|
||||
Self {
|
||||
store,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> {
|
||||
self.store.providers().await
|
||||
}
|
||||
|
||||
pub async fn create_provider(
|
||||
&self,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
self.store.create_provider(input).await
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
self.store.update_provider(provider_id, input).await
|
||||
}
|
||||
|
||||
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
|
||||
self.store.delete_provider(provider_id).await
|
||||
}
|
||||
|
||||
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
|
||||
self.store.provider_models(false).await
|
||||
}
|
||||
|
||||
pub async fn save_models(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
models: &[ProviderModelInput],
|
||||
) -> Result<Vec<ProviderModel>> {
|
||||
let mut saved = Vec::with_capacity(models.len());
|
||||
for model in models {
|
||||
saved.push(self.store.save_provider_model(provider_id, model).await?);
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
|
||||
self.store.delete_provider_model(model_hash).await
|
||||
}
|
||||
|
||||
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
|
||||
let provider = self
|
||||
.store
|
||||
.provider(provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let mut models = match provider.endpoint.provider_type {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
||||
openai_models(&self.client, &provider).await?
|
||||
}
|
||||
ProviderType::Anthropic => anthropic_models(&self.client, &provider).await?,
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
Ok(DiscoveredModels { models })
|
||||
}
|
||||
|
||||
pub async fn calls(&self, limit: i64) -> Result<Vec<LlmCallSummary>> {
|
||||
self.store.llm_calls(limit).await
|
||||
}
|
||||
|
||||
pub async fn call(&self, call_id: &str) -> Result<CallDetail> {
|
||||
let call = self
|
||||
.store
|
||||
.llm_call(call_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("LLM call {call_id}")))?;
|
||||
Ok(CallDetail {
|
||||
request: self.store.llm_call_request(call_id).await?,
|
||||
response_chunks: self.store.llm_call_chunks(call_id).await?,
|
||||
call,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn observability(&self) -> Result<ObservabilitySettings> {
|
||||
Ok(ObservabilitySettings {
|
||||
detailed: self.store.detailed_logging().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_observability(
|
||||
&self,
|
||||
settings: ObservabilitySettings,
|
||||
) -> Result<ObservabilitySettings> {
|
||||
self.store.set_detailed_logging(settings.detailed).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
async fn openai_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.bearer_auth(&provider.api_key);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Provider(format!(
|
||||
"model discovery failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
Ok(model_ids(body.get("data").unwrap_or(&body)))
|
||||
}
|
||||
|
||||
async fn anthropic_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut after_id = None::<String>;
|
||||
let mut found = BTreeSet::new();
|
||||
loop {
|
||||
let mut request = client
|
||||
.get(format!("{}/models", provider.endpoint.base_url))
|
||||
.query(&[("limit", "100")])
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.header("x-api-key", &provider.api_key);
|
||||
}
|
||||
if let Some(after_id) = &after_id {
|
||||
request = request.query(&[("after_id", after_id)]);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Provider(format!(
|
||||
"model discovery failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
found.extend(model_ids(body.get("data").unwrap_or(&body)));
|
||||
if body.get("has_more").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
break;
|
||||
}
|
||||
after_id = body
|
||||
.get("last_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if after_id.is_none() {
|
||||
return Err(Error::Provider(
|
||||
"Anthropic model response has_more without last_id".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(found.into_iter().collect())
|
||||
}
|
||||
|
||||
fn model_ids(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| match item {
|
||||
serde_json::Value::String(id) => Some(id.clone()),
|
||||
serde_json::Value::Object(object) => object
|
||||
.get("id")
|
||||
.or_else(|| object.get("name"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_custom_headers(
|
||||
mut request: reqwest::RequestBuilder,
|
||||
headers: &serde_json::Value,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
let object = headers
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
|
||||
for (name, value) in object {
|
||||
let value = value
|
||||
.as_str()
|
||||
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
|
||||
let name = HeaderName::try_from(name)
|
||||
.map_err(|error| Error::Config(format!("invalid header name: {error}")))?;
|
||||
let value = HeaderValue::try_from(value)
|
||||
.map_err(|error| Error::Config(format!("invalid header value: {error}")))?;
|
||||
request = request.header(name, value);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use axum::{extract::State, Json};
|
||||
use crate::Result;
|
||||
|
||||
use super::{ControlService, ObservabilitySettings};
|
||||
|
||||
pub async fn get(State(service): State<ControlService>) -> Result<Json<ObservabilitySettings>> {
|
||||
Ok(Json(service.observability().await?))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Json(settings): Json<ObservabilitySettings>,
|
||||
) -> Result<Json<ObservabilitySettings>> {
|
||||
Ok(Json(service.set_observability(settings).await?))
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
cursor::prompting::PromptCompiler,
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer,
|
||||
checkpoint::CheckpointBuilder,
|
||||
proto::agent::v1 as pb,
|
||||
request,
|
||||
session::CursorSession,
|
||||
tools::{
|
||||
codec, result::tool_result_channel, runtime::CursorToolRuntime, ClientToolEvent,
|
||||
ToolDispatcher,
|
||||
},
|
||||
},
|
||||
provider::Provider,
|
||||
run::{RunActor, RunRegistry},
|
||||
store::Store,
|
||||
};
|
||||
|
||||
use super::{inbox::OrderedInbox, CursorCommand, CursorSessionHandle};
|
||||
|
||||
pub struct CursorActor;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RunDependencies {
|
||||
pub store: Store,
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub compiler: PromptCompiler,
|
||||
pub run_registry: RunRegistry,
|
||||
}
|
||||
|
||||
impl CursorActor {
|
||||
pub(crate) fn spawn(
|
||||
handle: CursorSessionHandle,
|
||||
mut receiver: mpsc::Receiver<CursorCommand>,
|
||||
dependencies: RunDependencies,
|
||||
blob_sync: BlobSynchronizer,
|
||||
next_append_seqno: i64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut inbox = OrderedInbox::starting_at(next_append_seqno);
|
||||
let (results_tx, results_rx) = tool_result_channel();
|
||||
let tool_runtime = CursorToolRuntime::default();
|
||||
let tools = ToolDispatcher::with_results(tool_runtime.clone(), results_tx.clone());
|
||||
let mut run_resources = Some((results_rx, dependencies));
|
||||
loop {
|
||||
let command = match receiver.recv().await {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.cancel();
|
||||
break;
|
||||
}
|
||||
};
|
||||
match command {
|
||||
CursorCommand::Abort => {
|
||||
handle.cancel();
|
||||
}
|
||||
CursorCommand::Finished => {
|
||||
break;
|
||||
}
|
||||
CursorCommand::Append { seqno, message } => {
|
||||
for (_seqno, message) in inbox.push(seqno, *message) {
|
||||
{
|
||||
match message.message {
|
||||
Some(pb::agent_client_message::Message::RunRequest(
|
||||
request,
|
||||
)) => {
|
||||
if let Some((results, dependencies)) = run_resources.take()
|
||||
{
|
||||
let handle = handle.clone();
|
||||
let blob_sync = blob_sync.clone();
|
||||
let tools = tools.clone();
|
||||
let tool_runtime = tool_runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut checkpoint = CheckpointBuilder::new(
|
||||
dependencies.store.clone(),
|
||||
blob_sync.clone(),
|
||||
handle
|
||||
.parent()
|
||||
.map(|parent| parent.tool_call_id.clone()),
|
||||
request.conversation_state.clone(),
|
||||
);
|
||||
let parent = handle.parent().map(|parent| {
|
||||
(
|
||||
crate::model::RunId::new(&parent.run_id),
|
||||
parent.tool_call_id.clone(),
|
||||
)
|
||||
});
|
||||
let prepared = request::prepare(
|
||||
handle.request_id(),
|
||||
&request,
|
||||
parent,
|
||||
request::PrepareDependencies {
|
||||
compiler: &dependencies.compiler,
|
||||
store: &dependencies.store,
|
||||
checkpoint: &checkpoint,
|
||||
blob_sync: &blob_sync,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (prepared, context) = match prepared {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"failed to prepare Cursor Run"
|
||||
);
|
||||
let _ = crate::cursor::lifecycle::fail(
|
||||
&handle, &error,
|
||||
);
|
||||
let _ = handle
|
||||
.command(CursorCommand::Finished)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
checkpoint.configure(
|
||||
prepared.model.model_id.clone(),
|
||||
prepared.model.context_window_tokens,
|
||||
prepared.prompt.instructions.clone(),
|
||||
prepared.prompt.tools.clone(),
|
||||
context.dynamic_tools.keys().cloned().collect(),
|
||||
context.turn_user.clone(),
|
||||
);
|
||||
let cancellation = handle.cancellation();
|
||||
let (port, core) = crate::client::session(256);
|
||||
let actor = RunActor::new(
|
||||
dependencies.store.clone(),
|
||||
dependencies.provider,
|
||||
dependencies.run_registry,
|
||||
);
|
||||
let core_run =
|
||||
actor.spawn(prepared, port, cancellation).await;
|
||||
let session = CursorSession::new(
|
||||
handle.clone(),
|
||||
dependencies.store,
|
||||
context,
|
||||
core,
|
||||
super::session::CursorSessionRuntime {
|
||||
tools,
|
||||
results,
|
||||
checkpoint,
|
||||
tool_runtime,
|
||||
},
|
||||
);
|
||||
if let Err(error) = session.run().await {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"Cursor session failed"
|
||||
);
|
||||
handle.cancel();
|
||||
let _ = crate::cursor::lifecycle::fail(
|
||||
&handle, &error,
|
||||
);
|
||||
}
|
||||
let _ = core_run.await;
|
||||
let _ =
|
||||
handle.command(CursorCommand::Finished).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||
message,
|
||||
)) => {
|
||||
match codec::client_event(&message, &tool_runtime).await {
|
||||
Ok(codec::ClientExecEvent::Delta(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Message(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Completed(result)) => {
|
||||
results_tx.send(*result)
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Pending) => {}
|
||||
Err(error) => results_tx.send_error(error),
|
||||
}
|
||||
}
|
||||
Some(
|
||||
pb::agent_client_message::Message::ExecClientControlMessage(
|
||||
message,
|
||||
),
|
||||
) => {
|
||||
use pb::exec_client_control_message::Message;
|
||||
match message.message {
|
||||
Some(Message::StreamClose(close)) => {
|
||||
if tool_runtime.take_exec(close.id).await.is_some()
|
||||
{
|
||||
results_tx.send_error(crate::Error::Protocol(format!(
|
||||
"Exec stream closed before result for id: {}",
|
||||
close.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Some(Message::Throw(throw)) => {
|
||||
match tool_runtime.take_exec(throw.id).await {
|
||||
Some(pending) => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"Exec {} failed: {}",
|
||||
pending.call.call_id, throw.error
|
||||
)),
|
||||
),
|
||||
None => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"unknown ExecClientThrow id: {}",
|
||||
throw.id
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
Some(Message::Heartbeat(_)) | None => {}
|
||||
}
|
||||
}
|
||||
Some(
|
||||
pb::agent_client_message::Message::InteractionResponse(
|
||||
message,
|
||||
),
|
||||
) => match tools.interaction_response(&message).await {
|
||||
Ok(ClientToolEvent::Message(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(ClientToolEvent::Completed(completion)) => {
|
||||
results_tx.send(*completion)
|
||||
}
|
||||
Err(error) => results_tx.send_error(error),
|
||||
},
|
||||
Some(pb::agent_client_message::Message::KvClientMessage(
|
||||
message,
|
||||
)) => {
|
||||
let _ = blob_sync.handle_client(message).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::{agent::v1 as agent, aiserver::v1 as ai},
|
||||
cursor::{CursorCommand, CursorParent, CursorSessionRegistry},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub async fn append(
|
||||
registry: &CursorSessionRegistry,
|
||||
request: ai::BidiAppendRequest,
|
||||
parent: Option<CursorParent>,
|
||||
) -> Result<ai::BidiAppendResponse> {
|
||||
let request_id = request
|
||||
.request_id
|
||||
.as_ref()
|
||||
.map(|id| id.request_id.as_str())
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| Error::Protocol("BidiAppend request_id is required".into()))?;
|
||||
if !request.data_binary.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"BidiAppend data_binary is not part of the captured protocol".into(),
|
||||
));
|
||||
}
|
||||
if request.data.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"BidiAppend contains no AgentClientMessage".into(),
|
||||
));
|
||||
}
|
||||
let payload = hex::decode(&request.data)
|
||||
.map_err(|error| Error::Protocol(format!("invalid BidiAppend hex: {error}")))?;
|
||||
let message = agent::AgentClientMessage::decode(payload.as_slice())?;
|
||||
let handle = registry.get_or_create(request_id).await?;
|
||||
if let Some(parent) = parent {
|
||||
handle.set_parent(parent)?;
|
||||
}
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: request.append_seqno,
|
||||
message: Box::new(message),
|
||||
})
|
||||
.await?;
|
||||
Ok(ai::BidiAppendResponse {})
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
cursor::CursorSessionHandle,
|
||||
store::{BlobEdge, BlobId, Store},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
type BlobSetSender = oneshot::Sender<Result<()>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlobSynchronizer {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
request_id: String,
|
||||
store: Store,
|
||||
handle: CursorSessionHandle,
|
||||
next_id: AtomicU32,
|
||||
set_requests: Mutex<HashMap<u32, PendingSet>>,
|
||||
acked_blobs: Mutex<HashSet<BlobId>>,
|
||||
get_requests: Mutex<HashMap<u32, PendingGet>>,
|
||||
}
|
||||
|
||||
struct PendingSet {
|
||||
blob_id: BlobId,
|
||||
sent_at: std::time::Instant,
|
||||
result: BlobSetSender,
|
||||
}
|
||||
|
||||
struct PendingGet {
|
||||
blob_id: BlobId,
|
||||
result: oneshot::Sender<Result<Option<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl BlobSynchronizer {
|
||||
pub fn new(request_id: String, store: Store, handle: CursorSessionHandle) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
request_id,
|
||||
store,
|
||||
handle,
|
||||
next_id: AtomicU32::new(1),
|
||||
set_requests: Mutex::new(HashMap::new()),
|
||||
acked_blobs: Mutex::new(HashSet::new()),
|
||||
get_requests: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> &str {
|
||||
&self.inner.request_id
|
||||
}
|
||||
|
||||
pub async fn persist(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
|
||||
let id = self.inner.store.put_blob(data, edges).await?;
|
||||
self.ensure_set(&id, data).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn ensure_set(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
|
||||
if self.inner.acked_blobs.lock().await.contains(blob_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.inner.set_requests.lock().await.insert(
|
||||
id,
|
||||
PendingSet {
|
||||
blob_id: blob_id.clone(),
|
||||
sent_at: std::time::Instant::now(),
|
||||
result: sender,
|
||||
},
|
||||
);
|
||||
if let Err(error) = self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
pb::KvServerMessage {
|
||||
id,
|
||||
span_context: None,
|
||||
message: Some(pb::kv_server_message::Message::SetBlobArgs(
|
||||
pb::SetBlobArgs {
|
||||
blob_id: blob_id.as_bytes().to_vec(),
|
||||
blob_data: data.to_vec(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}) {
|
||||
self.inner.set_requests.lock().await.remove(&id);
|
||||
return Err(error);
|
||||
}
|
||||
let cancellation = self.inner.handle.cancellation();
|
||||
let result = tokio::select! {
|
||||
result = receiver => result.map_err(|_| Error::Protocol("KV SET response channel closed".into()))?,
|
||||
_ = cancellation.cancelled() => Err(Error::Cancelled),
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV SET timed out: {}", blob_id.to_base64()))),
|
||||
};
|
||||
if result.is_err() {
|
||||
self.inner.set_requests.lock().await.remove(&id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(data) = self.inner.store.get_blob(blob_id).await? {
|
||||
return Ok(Some(data));
|
||||
}
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.inner.get_requests.lock().await.insert(
|
||||
id,
|
||||
PendingGet {
|
||||
blob_id: blob_id.clone(),
|
||||
result: sender,
|
||||
},
|
||||
);
|
||||
self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
pb::KvServerMessage {
|
||||
id,
|
||||
span_context: None,
|
||||
message: Some(pb::kv_server_message::Message::GetBlobArgs(
|
||||
pb::GetBlobArgs {
|
||||
blob_id: blob_id.as_bytes().to_vec(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
})?;
|
||||
let cancellation = self.inner.handle.cancellation();
|
||||
let result = tokio::select! {
|
||||
result = receiver => result.map_err(|_| Error::Protocol("KV GET response channel closed".into()))?,
|
||||
_ = cancellation.cancelled() => Err(Error::Cancelled),
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV GET timed out: {}", blob_id.to_base64()))),
|
||||
};
|
||||
if result.is_err() {
|
||||
self.inner.get_requests.lock().await.remove(&id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn cache_received(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
|
||||
let actual = BlobId::digest(data);
|
||||
if actual != *blob_id {
|
||||
return Err(Error::Protocol(format!(
|
||||
"received Blob hash mismatch: expected {}, got {}",
|
||||
blob_id.to_base64(),
|
||||
actual.to_base64()
|
||||
)));
|
||||
}
|
||||
self.inner.store.put_blob(data, &[]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_client(&self, message: pb::KvClientMessage) -> Result<()> {
|
||||
match message.message {
|
||||
Some(pb::kv_client_message::Message::SetBlobResult(result)) => {
|
||||
if let Some(pending) = self.inner.set_requests.lock().await.remove(&message.id) {
|
||||
if let Some(error) = result.error {
|
||||
tracing::error!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
blob_id = pending.blob_id.to_base64(),
|
||||
error = error.message,
|
||||
"Cursor rejected Blob SET"
|
||||
);
|
||||
let _ = pending.result.send(Err(Error::Protocol(format!(
|
||||
"KV SET {}: {}",
|
||||
pending.blob_id.to_base64(),
|
||||
error.message
|
||||
))));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
blob_id = pending.blob_id.to_base64(),
|
||||
elapsed_ms = pending.sent_at.elapsed().as_millis(),
|
||||
"Cursor acknowledged Blob SET"
|
||||
);
|
||||
self.inner.acked_blobs.lock().await.insert(pending.blob_id);
|
||||
let _ = pending.result.send(Ok(()));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
"unknown Cursor Blob SET acknowledgement"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(pb::kv_client_message::Message::GetBlobResult(result)) => {
|
||||
if let Some(pending) = self.inner.get_requests.lock().await.remove(&message.id) {
|
||||
let value = if let Some(error) = result.error {
|
||||
Err(Error::Protocol(format!("KV GET: {}", error.message)))
|
||||
} else if let Some(data) = result.blob_data {
|
||||
let actual = BlobId::digest(&data);
|
||||
if actual != pending.blob_id {
|
||||
Err(Error::Protocol(format!(
|
||||
"KV GET Blob hash mismatch: expected {}, got {}",
|
||||
pending.blob_id.to_base64(),
|
||||
actual.to_base64()
|
||||
)))
|
||||
} else {
|
||||
self.inner.store.put_blob(&data, &[]).await?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
let _ = pending.result.send(value);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
"unknown Cursor Blob GET response"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{prompting::fold_derived_state, proto::agent::v1 as pb},
|
||||
model::{CanonicalMessage, MessageContent},
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn build_derived_state(
|
||||
&self,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<(Vec<BlobId>, Option<BlobId>)> {
|
||||
let state = fold_derived_state(messages);
|
||||
let todo_values = state
|
||||
.todos
|
||||
.as_ref()
|
||||
.map(|value| {
|
||||
value
|
||||
.get("todos")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite state is missing todos[]".into()))
|
||||
})
|
||||
.transpose()?;
|
||||
let mut todo_ids = Vec::new();
|
||||
for (index, todo) in todo_values.into_iter().flatten().enumerate() {
|
||||
let status = match todo
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing status".into()))?
|
||||
{
|
||||
"in_progress" => pb::TodoStatus::InProgress,
|
||||
"completed" => pb::TodoStatus::Completed,
|
||||
"cancelled" => pb::TodoStatus::Cancelled,
|
||||
"pending" => pb::TodoStatus::Pending,
|
||||
status => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown TodoWrite status: {status}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let message = pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing id".into()))?
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing content".into()))?
|
||||
.into(),
|
||||
status: status as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: todo
|
||||
.get("dependencies")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
message.encode(&mut encoded)?;
|
||||
let id = BlobId::digest(&encoded);
|
||||
if self.base.todos.get(index).map(|raw| raw.as_slice()) == Some(id.as_bytes()) {
|
||||
todo_ids.push(id);
|
||||
} else {
|
||||
todo_ids.push(self.sync.persist(&encoded, &[]).await?);
|
||||
}
|
||||
}
|
||||
let plan_id = if let Some(value) = state.plan {
|
||||
let text = value
|
||||
.get("plan")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.or_else(|| value.get("overview").and_then(serde_json::Value::as_str))
|
||||
.ok_or_else(|| Error::Protocol("plan state has no textual plan".into()))?;
|
||||
let mut encoded = Vec::new();
|
||||
pb::ConversationPlan { plan: text.into() }.encode(&mut encoded)?;
|
||||
let id = BlobId::digest(&encoded);
|
||||
if self.base.plan.as_deref() == Some(id.as_bytes()) {
|
||||
Some(id)
|
||||
} else {
|
||||
Some(self.sync.persist(&encoded, &[]).await?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((todo_ids, plan_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_current_step_state(
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Option<pb::CommunicateUpdateTurnState> {
|
||||
let result_indices = messages
|
||||
.iter()
|
||||
.filter_map(|message| match &message.content {
|
||||
MessageContent::ToolResult(result) => {
|
||||
update_message_index(&result.content).map(|index| (result.call_id.as_str(), index))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut state = pb::CommunicateUpdateTurnState::default();
|
||||
for message in messages {
|
||||
let MessageContent::Assistant { tool_calls, .. } = &message.content else {
|
||||
continue;
|
||||
};
|
||||
for call in tool_calls {
|
||||
if normalize(&call.name) != "updatecurrentstep" {
|
||||
continue;
|
||||
}
|
||||
if let (Some(step), Some(message_index)) = (
|
||||
call.arguments
|
||||
.get("current_step")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
result_indices.get(call.call_id.as_str()),
|
||||
) {
|
||||
state.history.push(pb::CommunicateUpdateHistoryEntry {
|
||||
step: step.into(),
|
||||
message_index: *message_index,
|
||||
});
|
||||
}
|
||||
if let Some(summary) = call
|
||||
.arguments
|
||||
.get("final_summary")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
state.final_summary = Some(summary.into());
|
||||
}
|
||||
if let Some(subtitle) = call
|
||||
.arguments
|
||||
.get("completed_subtitle")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
state.completed_subtitle = Some(subtitle.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!state.history.is_empty()
|
||||
|| state.final_summary.is_some()
|
||||
|| state.completed_subtitle.is_some())
|
||||
.then_some(state)
|
||||
}
|
||||
|
||||
fn update_message_index(output: &str) -> Option<u32> {
|
||||
let value: serde_json::Value = serde_json::from_str(output).ok()?;
|
||||
value
|
||||
.get("success")
|
||||
.and_then(|success| success.get("message_index"))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|index| u32::try_from(index).ok())
|
||||
}
|
||||
|
||||
fn normalize(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
|
||||
|
||||
#[test]
|
||||
fn update_current_step_is_folded_from_canonical_messages() {
|
||||
let messages = vec![
|
||||
CanonicalMessage {
|
||||
message_id: "assistant".into(),
|
||||
role: Role::Assistant,
|
||||
origin: Origin::Assistant,
|
||||
content: MessageContent::Assistant {
|
||||
text: String::new(),
|
||||
thinking: String::new(),
|
||||
tool_round_id: Some("round".into()),
|
||||
replay_state: None,
|
||||
tool_calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: "call".into(),
|
||||
name: "UpdateCurrentStep".into(),
|
||||
arguments: serde_json::json!({
|
||||
"current_step": "Inspecting protocol",
|
||||
"final_summary": "Protocol verified.",
|
||||
"completed_subtitle": "Verified protocol flow"
|
||||
}),
|
||||
}],
|
||||
},
|
||||
runtime_event_id: None,
|
||||
},
|
||||
CanonicalMessage {
|
||||
message_id: "result".into(),
|
||||
role: Role::Tool,
|
||||
origin: Origin::Tool,
|
||||
content: MessageContent::ToolResult(ToolResultContent {
|
||||
call_id: "call".into(),
|
||||
name: "UpdateCurrentStep".into(),
|
||||
content: serde_json::json!({
|
||||
"success": {"current_step": "Inspecting protocol", "message_index": 3}
|
||||
})
|
||||
.to_string(),
|
||||
is_error: false,
|
||||
}),
|
||||
runtime_event_id: None,
|
||||
},
|
||||
];
|
||||
let state = update_current_step_state(&messages).unwrap();
|
||||
assert_eq!(state.history[0].step, "Inspecting protocol");
|
||||
assert_eq!(state.history[0].message_index, 3);
|
||||
assert_eq!(state.final_summary.as_deref(), Some("Protocol verified."));
|
||||
assert_eq!(
|
||||
state.completed_subtitle.as_deref(),
|
||||
Some("Verified protocol flow")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
mod derived;
|
||||
mod recovery;
|
||||
mod roots;
|
||||
mod turns;
|
||||
pub(crate) mod worker;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer, presentation::PresentationDelta, projection,
|
||||
proto::agent::v1 as pb, CursorSessionHandle,
|
||||
},
|
||||
model::{CanonicalMessage, ToolCall, ToolDefinition, ToolRoundAssistant},
|
||||
store::Store,
|
||||
Result,
|
||||
};
|
||||
|
||||
use roots::RootFrontier;
|
||||
use turns::TurnFrontier;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CheckpointBuilder {
|
||||
store: Store,
|
||||
sync: BlobSynchronizer,
|
||||
parent_tool_call_id: Option<String>,
|
||||
base: pb::ConversationStateStructure,
|
||||
model: String,
|
||||
max_context_tokens: Option<u64>,
|
||||
instructions: String,
|
||||
tool_definitions: Vec<ToolDefinition>,
|
||||
allowed_tools: Vec<String>,
|
||||
dynamic_tools: HashSet<String>,
|
||||
turn_user: Option<pb::UserMessage>,
|
||||
roots: Option<RootFrontier>,
|
||||
turn: Option<TurnFrontier>,
|
||||
turns_initialized: bool,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
sync: BlobSynchronizer,
|
||||
parent_tool_call_id: Option<String>,
|
||||
base: Option<pb::ConversationStateStructure>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
sync,
|
||||
parent_tool_call_id,
|
||||
base: base.unwrap_or_default(),
|
||||
model: String::new(),
|
||||
max_context_tokens: None,
|
||||
instructions: String::new(),
|
||||
tool_definitions: Vec::new(),
|
||||
allowed_tools: Vec::new(),
|
||||
dynamic_tools: HashSet::new(),
|
||||
turn_user: None,
|
||||
roots: None,
|
||||
turn: None,
|
||||
turns_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
model: String,
|
||||
max_context_tokens: Option<u64>,
|
||||
instructions: String,
|
||||
tool_definitions: Vec<ToolDefinition>,
|
||||
dynamic_tools: HashSet<String>,
|
||||
turn_user: Option<pb::UserMessage>,
|
||||
) {
|
||||
self.model = model;
|
||||
self.max_context_tokens = max_context_tokens;
|
||||
self.instructions = instructions;
|
||||
self.allowed_tools = tool_definitions
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect();
|
||||
self.tool_definitions = tool_definitions;
|
||||
self.dynamic_tools = dynamic_tools;
|
||||
self.turn_user = turn_user;
|
||||
}
|
||||
|
||||
pub(crate) fn record_context_tokens(&mut self, used_tokens: Option<u64>) {
|
||||
let Some(used_tokens) = used_tokens else {
|
||||
return;
|
||||
};
|
||||
let max_tokens = self
|
||||
.base
|
||||
.token_details
|
||||
.as_ref()
|
||||
.map(|details| details.max_tokens as u64)
|
||||
.filter(|tokens| *tokens != 0)
|
||||
.or(self.max_context_tokens);
|
||||
let Some(max_tokens) = max_tokens else {
|
||||
return;
|
||||
};
|
||||
let details = self.base.token_details.get_or_insert_with(Default::default);
|
||||
details.used_tokens = used_tokens.min(u32::MAX as u64) as u32;
|
||||
details.max_tokens = max_tokens.min(u32::MAX as u64) as u32;
|
||||
details.prompt_context_usage_tree = None;
|
||||
details.prompt_context_usage_snapshot_blob_id = None;
|
||||
}
|
||||
|
||||
pub async fn settled(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
self.build_state(messages, mode, Vec::new(), presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn staged_tool_round(
|
||||
&mut self,
|
||||
stable_messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
assistant: &ToolRoundAssistant,
|
||||
calls: &[ToolCall],
|
||||
started_at_ms: u64,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let pending = projection::staged_tool_round(
|
||||
assistant,
|
||||
calls,
|
||||
&self.model,
|
||||
&self.allowed_tools,
|
||||
&self.dynamic_tools,
|
||||
started_at_ms,
|
||||
)?;
|
||||
self.build_state(stable_messages, mode, vec![pending], presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn staged_final(
|
||||
&mut self,
|
||||
stable_messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
assistant: &CanonicalMessage,
|
||||
started_at_ms: u64,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let pending = projection::staged_final(
|
||||
assistant,
|
||||
&self.model,
|
||||
&self.allowed_tools,
|
||||
&self.dynamic_tools,
|
||||
started_at_ms,
|
||||
)?;
|
||||
self.build_state(stable_messages, mode, vec![pending], presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_state(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
pending_tool_calls: Vec<String>,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let root_ids = self.project_roots(messages).await?;
|
||||
let turn_ids = self.project_turns(mode, presentation).await?;
|
||||
let (todo_ids, plan_id) = self.build_derived_state(messages).await?;
|
||||
self.base.todos = todo_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
self.base.plan = plan_id.as_ref().map(|id| id.as_bytes().to_vec());
|
||||
let communicate_update_states_by_parent_tool_call_id = self
|
||||
.parent_tool_call_id
|
||||
.as_ref()
|
||||
.and_then(|parent| {
|
||||
derived::update_current_step_state(messages).map(|state| (parent.clone(), state))
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for path in &presentation.read_paths {
|
||||
if !self.base.read_paths.contains(path) {
|
||||
self.base.read_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
let mut checkpoint = self.base.clone();
|
||||
checkpoint.root_prompt_messages_json =
|
||||
root_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
checkpoint.turns = turn_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
checkpoint.pending_tool_calls = pending_tool_calls;
|
||||
checkpoint.mode = Some(mode);
|
||||
checkpoint.communicate_update_states_by_parent_tool_call_id =
|
||||
communicate_update_states_by_parent_tool_call_id;
|
||||
if let Some(details) = checkpoint.token_details.as_mut() {
|
||||
details.breakdown = Some(crate::cursor::usage::breakdown(
|
||||
details.used_tokens,
|
||||
details.max_tokens,
|
||||
details.breakdown.as_ref(),
|
||||
&self.instructions,
|
||||
&self.tool_definitions,
|
||||
&self.dynamic_tools,
|
||||
messages,
|
||||
)?);
|
||||
}
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
&self,
|
||||
handle: &CursorSessionHandle,
|
||||
checkpoint: &pb::ConversationStateStructure,
|
||||
) -> Result<()> {
|
||||
tracing::debug!(
|
||||
request_id = self.sync.request_id(),
|
||||
stable_roots = checkpoint.root_prompt_messages_json.len(),
|
||||
pending_assistants = checkpoint.pending_tool_calls.len(),
|
||||
"publishing Cursor checkpoint"
|
||||
);
|
||||
handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(
|
||||
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint.clone()),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use crate::{
|
||||
cursor::{projection, proto::agent::v1 as pb},
|
||||
model::CanonicalMessage,
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub async fn import_prefetched(&self, blobs: &[pb::PreFetchedBlob]) -> Result<()> {
|
||||
for blob in blobs {
|
||||
let expected = BlobId::from_bytes(&blob.id)?;
|
||||
let actual = self.store.put_blob(&blob.value, &[]).await?;
|
||||
if expected != actual {
|
||||
return Err(Error::Protocol(format!(
|
||||
"prefetched Blob hash mismatch: {}",
|
||||
expected.to_base64()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hydrate_messages(
|
||||
&self,
|
||||
state: Option<&pb::ConversationStateStructure>,
|
||||
) -> Result<Vec<CanonicalMessage>> {
|
||||
let mut messages = Vec::new();
|
||||
let Some(state) = state else {
|
||||
return Ok(messages);
|
||||
};
|
||||
for (ordinal, raw_id) in state.root_prompt_messages_json.iter().enumerate() {
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
let Some(data) = self.sync.get(&id).await? else {
|
||||
return Err(Error::Protocol(format!(
|
||||
"missing message Blob {}",
|
||||
id.to_base64()
|
||||
)));
|
||||
};
|
||||
messages.push(projection::decode(
|
||||
&data,
|
||||
format!("cursor-root:{}:{ordinal}", id.to_base64()),
|
||||
)?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use crate::{cursor::projection, model::CanonicalMessage, store::BlobId, Error, Result};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RootFrontier {
|
||||
pub(super) ids: Vec<BlobId>,
|
||||
pub(super) generated: Vec<Vec<u8>>,
|
||||
pub(super) base_count: usize,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn project_roots(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<Vec<BlobId>> {
|
||||
let wire_messages = projection::stable_messages(&self.instructions, messages, &self.model)?;
|
||||
self.ensure_roots()?;
|
||||
let replacement = self
|
||||
.roots
|
||||
.as_ref()
|
||||
.and_then(|roots| changed_system_root(roots, &wire_messages));
|
||||
if let Some(message) = replacement {
|
||||
let id = self.sync.persist(&message, &[]).await?;
|
||||
self.roots
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?
|
||||
.ids[0] = id;
|
||||
}
|
||||
let roots = self
|
||||
.roots
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?;
|
||||
if wire_messages.len() < roots.ids.len() {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor stable history shrank from {} to {} roots",
|
||||
roots.ids.len(),
|
||||
wire_messages.len()
|
||||
)));
|
||||
}
|
||||
for (index, expected) in roots.generated.iter().enumerate() {
|
||||
let wire_index = roots.base_count + index;
|
||||
if wire_messages.get(wire_index) != Some(expected) {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor stable root changed at index {wire_index}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for message in wire_messages.iter().skip(roots.ids.len()) {
|
||||
roots.ids.push(self.sync.persist(message, &[]).await?);
|
||||
roots.generated.push(message.clone());
|
||||
}
|
||||
Ok(roots.ids.clone())
|
||||
}
|
||||
|
||||
fn ensure_roots(&mut self) -> Result<()> {
|
||||
if self.roots.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let ids = self
|
||||
.base
|
||||
.root_prompt_messages_json
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
self.roots = Some(RootFrontier {
|
||||
base_count: ids.len(),
|
||||
ids,
|
||||
generated: Vec::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_system_root(roots: &RootFrontier, messages: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
roots
|
||||
.ids
|
||||
.first()
|
||||
.zip(messages.first())
|
||||
.filter(|(current, message)| **current != BlobId::digest(message))
|
||||
.map(|(_, message)| message.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_replaces_only_the_system_root() {
|
||||
let previous = b"previous prompt".to_vec();
|
||||
let current = b"current prompt".to_vec();
|
||||
let roots = RootFrontier {
|
||||
ids: vec![BlobId::digest(&previous), BlobId::digest(b"user")],
|
||||
generated: Vec::new(),
|
||||
base_count: 2,
|
||||
};
|
||||
assert_eq!(
|
||||
changed_system_root(&roots, &[current.clone(), b"user".to_vec()]),
|
||||
Some(current)
|
||||
);
|
||||
assert_eq!(
|
||||
changed_system_root(&roots, &[previous, b"user".to_vec()]),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb},
|
||||
store::{BlobEdge, BlobId},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct TurnFrontier {
|
||||
pub(super) preceding: Vec<BlobId>,
|
||||
pub(super) current_id: Option<BlobId>,
|
||||
pub(super) current: pb::AgentConversationTurnStructure,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn project_turns(
|
||||
&mut self,
|
||||
mode: i32,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<Vec<BlobId>> {
|
||||
self.ensure_turn(mode).await?;
|
||||
let Some(turn) = self.turn.as_mut() else {
|
||||
return self
|
||||
.base
|
||||
.turns
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect();
|
||||
};
|
||||
let changed = !presentation.steps.is_empty();
|
||||
for step in &presentation.steps {
|
||||
let mut encoded = Vec::new();
|
||||
step.encode(&mut encoded)?;
|
||||
let id = self.sync.persist(&encoded, &[]).await?;
|
||||
turn.current.steps.push(id.as_bytes().to_vec());
|
||||
}
|
||||
if changed || turn.current_id.is_none() {
|
||||
let wrapper = pb::ConversationTurnStructure {
|
||||
turn: Some(
|
||||
pb::conversation_turn_structure::Turn::AgentConversationTurn(
|
||||
turn.current.clone(),
|
||||
),
|
||||
),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
wrapper.encode(&mut encoded)?;
|
||||
let mut edges = Vec::with_capacity(turn.current.steps.len() + 1);
|
||||
edges.push(BlobEdge {
|
||||
child: BlobId::from_bytes(&turn.current.user_message)?,
|
||||
field_name: "agent_conversation_turn.user_message".into(),
|
||||
});
|
||||
for (index, raw_id) in turn.current.steps.iter().enumerate() {
|
||||
edges.push(BlobEdge {
|
||||
child: BlobId::from_bytes(raw_id)?,
|
||||
field_name: format!("agent_conversation_turn.steps[{index}]"),
|
||||
});
|
||||
}
|
||||
turn.current_id = Some(self.sync.persist(&encoded, &edges).await?);
|
||||
}
|
||||
let mut ids = turn.preceding.clone();
|
||||
ids.push(
|
||||
turn.current_id
|
||||
.clone()
|
||||
.ok_or_else(|| Error::Protocol("Cursor current Turn has no BlobID".into()))?,
|
||||
);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn ensure_turn(&mut self, mode: i32) -> Result<()> {
|
||||
if self.turns_initialized {
|
||||
return Ok(());
|
||||
}
|
||||
self.turns_initialized = true;
|
||||
let base_ids = self
|
||||
.base
|
||||
.turns
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if let Some(mut user) = self.turn_user.clone() {
|
||||
user.mode = mode;
|
||||
let mut encoded = Vec::new();
|
||||
user.encode(&mut encoded)?;
|
||||
let user_id = self.sync.persist(&encoded, &[]).await?;
|
||||
self.turn = Some(TurnFrontier {
|
||||
preceding: base_ids,
|
||||
current_id: None,
|
||||
current: pb::AgentConversationTurnStructure {
|
||||
user_message: user_id.as_bytes().to_vec(),
|
||||
steps: Vec::new(),
|
||||
request_id: Some(self.sync.request_id().into()),
|
||||
encrypted_model: None,
|
||||
dynamic_tool_count: None,
|
||||
send_message_step_indices: Vec::new(),
|
||||
},
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let Some((current_id, preceding)) = base_ids.split_last() else {
|
||||
return Ok(());
|
||||
};
|
||||
let data = self.sync.get(current_id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"missing current Turn Blob {}",
|
||||
current_id.to_base64()
|
||||
))
|
||||
})?;
|
||||
let wrapper = pb::ConversationTurnStructure::decode(data.as_slice())?;
|
||||
let Some(pb::conversation_turn_structure::Turn::AgentConversationTurn(current)) =
|
||||
wrapper.turn
|
||||
else {
|
||||
return Err(Error::Protocol(
|
||||
"current Cursor Turn is not an agent conversation turn".into(),
|
||||
));
|
||||
};
|
||||
self.turn = Some(TurnFrontier {
|
||||
preceding: preceding.to_vec(),
|
||||
current_id: Some(current_id.clone()),
|
||||
current,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb, CursorSessionHandle},
|
||||
model::{RevisionId, ToolRoundId},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
pub(crate) struct CheckpointJob {
|
||||
pub kind: CheckpointKind,
|
||||
pub presentation: PresentationDelta,
|
||||
pub context_tokens: Option<u64>,
|
||||
pub ready: Option<oneshot::Sender<std::result::Result<(), String>>>,
|
||||
}
|
||||
|
||||
pub(crate) enum CheckpointKind {
|
||||
Settled(RevisionId),
|
||||
ToolStarted {
|
||||
round_id: ToolRoundId,
|
||||
stable_revision_id: RevisionId,
|
||||
},
|
||||
ToolSettled(RevisionId),
|
||||
Final {
|
||||
revision_id: RevisionId,
|
||||
result: oneshot::Sender<Result<FinalCheckpoints>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct FinalCheckpoints {
|
||||
pub staged: pb::ConversationStateStructure,
|
||||
pub settled: pb::ConversationStateStructure,
|
||||
}
|
||||
|
||||
pub(crate) struct CheckpointWorker {
|
||||
pub jobs: mpsc::Sender<CheckpointJob>,
|
||||
pub failures: mpsc::Receiver<Error>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CheckpointWorker {
|
||||
pub fn spawn(
|
||||
store: Store,
|
||||
mut builder: CheckpointBuilder,
|
||||
handle: CursorSessionHandle,
|
||||
mode: i32,
|
||||
) -> Self {
|
||||
let (jobs, mut receiver) = mpsc::channel::<CheckpointJob>(32);
|
||||
let (failures, failure_receiver) = mpsc::channel(1);
|
||||
let task = tokio::spawn(async move {
|
||||
while let Some(job) = receiver.recv().await {
|
||||
builder.record_context_tokens(job.context_tokens);
|
||||
let presentation = job.presentation;
|
||||
let ready = job.ready;
|
||||
let result = match job.kind {
|
||||
CheckpointKind::Settled(revision_id)
|
||||
| CheckpointKind::ToolSettled(revision_id) => {
|
||||
publish_settled(
|
||||
&store,
|
||||
&mut builder,
|
||||
&handle,
|
||||
mode,
|
||||
revision_id,
|
||||
&presentation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CheckpointKind::ToolStarted {
|
||||
round_id,
|
||||
stable_revision_id,
|
||||
} => {
|
||||
publish_started(
|
||||
&store,
|
||||
&mut builder,
|
||||
&handle,
|
||||
mode,
|
||||
round_id,
|
||||
stable_revision_id,
|
||||
&presentation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CheckpointKind::Final {
|
||||
revision_id,
|
||||
result,
|
||||
} => {
|
||||
let checkpoints =
|
||||
build_final(&store, &mut builder, mode, revision_id, &presentation)
|
||||
.await;
|
||||
let _ = result.send(checkpoints);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(error.to_string()));
|
||||
}
|
||||
tracing::error!(%error, "failed to build or publish Cursor checkpoint");
|
||||
let _ = failures.send(error).await;
|
||||
break;
|
||||
}
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Ok(()));
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
jobs,
|
||||
failures: failure_receiver,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort(&self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_settled(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
handle: &CursorSessionHandle,
|
||||
mode: i32,
|
||||
revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<()> {
|
||||
let messages = store.load_revision_messages(revision_id).await?;
|
||||
let checkpoint = builder.settled(&messages, mode, presentation).await?;
|
||||
builder.publish(handle, &checkpoint).await
|
||||
}
|
||||
|
||||
async fn publish_started(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
handle: &CursorSessionHandle,
|
||||
mode: i32,
|
||||
round_id: ToolRoundId,
|
||||
stable_revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<()> {
|
||||
let round = store
|
||||
.tool_round(&round_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Store(format!("checkpoint tool round not found: {round_id}")))?;
|
||||
let messages = store.load_revision_messages(stable_revision_id).await?;
|
||||
let checkpoint = builder
|
||||
.staged_tool_round(
|
||||
&messages,
|
||||
mode,
|
||||
&round.assistant,
|
||||
&round.calls,
|
||||
round.created_at_ms,
|
||||
presentation,
|
||||
)
|
||||
.await?;
|
||||
builder.publish(handle, &checkpoint).await
|
||||
}
|
||||
|
||||
async fn build_final(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
mode: i32,
|
||||
revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<FinalCheckpoints> {
|
||||
let messages = store.load_revision_messages(revision_id).await?;
|
||||
let (assistant, stable) = messages
|
||||
.split_last()
|
||||
.ok_or_else(|| Error::Store("final revision contains no assistant".into()))?;
|
||||
let started_at_ms = crate::cursor::tools::runtime::now_ms();
|
||||
let staged = builder
|
||||
.staged_final(stable, mode, assistant, started_at_ms, presentation)
|
||||
.await?;
|
||||
let settled = builder
|
||||
.settled(&messages, mode, &PresentationDelta::default())
|
||||
.await?;
|
||||
Ok(FinalCheckpoints { staged, settled })
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
use crate::cursor::proto::agent::v1 as pb;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CursorCommand {
|
||||
Append {
|
||||
seqno: i64,
|
||||
message: Box<pb::AgentClientMessage>,
|
||||
},
|
||||
Abort,
|
||||
Finished,
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use prost::Message;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const END_STREAM_FLAG: u8 = 0x02;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConnectCode {
|
||||
Canceled,
|
||||
InvalidArgument,
|
||||
NotFound,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl ConnectCode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canceled => "canceled",
|
||||
Self::InvalidArgument => "invalid_argument",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Unavailable => "unavailable",
|
||||
Self::Internal => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ConnectErrorDetail {
|
||||
#[serde(rename = "type")]
|
||||
pub type_name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConnectStreamError {
|
||||
pub code: ConnectCode,
|
||||
pub message: String,
|
||||
pub details: Vec<ConnectErrorDetail>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EndStreamResponse<'a> {
|
||||
error: WireError<'a>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WireError<'a> {
|
||||
code: &'static str,
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
message: &'a str,
|
||||
#[serde(skip_serializing_if = "details_are_empty")]
|
||||
details: &'a [ConnectErrorDetail],
|
||||
}
|
||||
|
||||
fn details_are_empty(details: &&[ConnectErrorDetail]) -> bool {
|
||||
details.is_empty()
|
||||
}
|
||||
|
||||
pub fn encode_message<M: Message>(message: &M) -> Result<Bytes> {
|
||||
let len = message.encoded_len();
|
||||
let mut output = BytesMut::with_capacity(5 + len);
|
||||
output.put_u8(0);
|
||||
output.put_u32(len as u32);
|
||||
message.encode(&mut output)?;
|
||||
Ok(output.freeze())
|
||||
}
|
||||
|
||||
pub fn encode_end_stream() -> Bytes {
|
||||
encode_end_stream_payload(b"{}")
|
||||
}
|
||||
|
||||
pub fn encode_error_end_stream(error: &ConnectStreamError) -> Result<Bytes> {
|
||||
let payload = serde_json::to_vec(&EndStreamResponse {
|
||||
error: WireError {
|
||||
code: error.code.as_str(),
|
||||
message: &error.message,
|
||||
details: &error.details,
|
||||
},
|
||||
})?;
|
||||
Ok(encode_end_stream_payload(&payload))
|
||||
}
|
||||
|
||||
fn encode_end_stream_payload(payload: &[u8]) -> Bytes {
|
||||
let mut output = BytesMut::with_capacity(5 + payload.len());
|
||||
output.put_u8(END_STREAM_FLAG);
|
||||
output.put_u32(payload.len() as u32);
|
||||
output.extend_from_slice(payload);
|
||||
output.freeze()
|
||||
}
|
||||
|
||||
pub fn decode_unary<M: Message + Default>(body: &[u8]) -> Result<M> {
|
||||
if body.len() >= 5 {
|
||||
let flags = body[0];
|
||||
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
|
||||
if flags & END_STREAM_FLAG == 0 && length == body.len() - 5 {
|
||||
return Ok(M::decode(&body[5..])?);
|
||||
}
|
||||
}
|
||||
Ok(M::decode(body)?)
|
||||
}
|
||||
|
||||
pub fn decode_frames(mut body: &[u8]) -> Result<Vec<(u8, Bytes)>> {
|
||||
let mut frames = Vec::new();
|
||||
while !body.is_empty() {
|
||||
if body.len() < 5 {
|
||||
return Err(Error::Protocol("truncated Connect envelope".into()));
|
||||
}
|
||||
let flags = body[0];
|
||||
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
|
||||
body = &body[5..];
|
||||
if body.len() < length {
|
||||
return Err(Error::Protocol("truncated Connect payload".into()));
|
||||
}
|
||||
frames.push((flags, Bytes::copy_from_slice(&body[..length])));
|
||||
body = &body[length..];
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{DefaultBodyLimit, Extension, State},
|
||||
http::{header, HeaderMap, HeaderValue, Response, StatusCode},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use tower_http::decompression::RequestDecompressionLayer;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
bidi_append, connect, model_catalog,
|
||||
proto::{agent::v1 as agent, aiserver::v1 as ai},
|
||||
proxy::{self, CursorProxy},
|
||||
run_sse,
|
||||
},
|
||||
cursor::{CursorParent, CursorSessionRegistry},
|
||||
Result,
|
||||
};
|
||||
|
||||
pub fn router(registry: CursorSessionRegistry) -> Result<Router> {
|
||||
let proxy = CursorProxy::cursor()?;
|
||||
Ok(Router::new()
|
||||
.route("/agent.v1.AgentService/RunSSE", post(run_sse_handler))
|
||||
.route(
|
||||
"/aiserver.v1.BidiService/BidiAppend",
|
||||
post(bidi_append_handler),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/AvailableModels",
|
||||
post(model_catalog::available_models),
|
||||
)
|
||||
.route(
|
||||
"/agent.v1.AgentService/GetUsableModels",
|
||||
post(model_catalog::usable_models),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/GetUsableModels",
|
||||
post(model_catalog::usable_models),
|
||||
)
|
||||
.route_layer(DefaultBodyLimit::disable())
|
||||
.route_layer(RequestDecompressionLayer::new())
|
||||
.fallback(proxy::forward)
|
||||
.method_not_allowed_fallback(proxy::forward)
|
||||
.layer(Extension(proxy))
|
||||
.with_state(registry))
|
||||
}
|
||||
|
||||
async fn run_sse_handler(
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
body: Bytes,
|
||||
) -> Result<Response<axum::body::Body>> {
|
||||
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
|
||||
run_sse::stream(®istry, &request.request_id).await
|
||||
}
|
||||
|
||||
async fn bidi_append_handler(
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response<axum::body::Body>> {
|
||||
let request: ai::BidiAppendRequest = connect::decode_unary(&body)?;
|
||||
let parent = parent_headers(&headers)?;
|
||||
bidi_append::append(®istry, request, parent).await?;
|
||||
let mut response = Response::new(axum::body::Body::empty());
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/proto"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> {
|
||||
let run_id = header_text(headers, "x-parent-request-id")?;
|
||||
let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?;
|
||||
match (run_id, tool_call_id) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(run_id), Some(tool_call_id)) => Ok(Some(CursorParent {
|
||||
run_id: run_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
})),
|
||||
_ => Err(crate::Error::Protocol(
|
||||
"Cursor subagent request must include both parent headers".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
|
||||
headers
|
||||
.get(name)
|
||||
.map(|value| value.to_str())
|
||||
.transpose()
|
||||
.map_err(|error| crate::Error::Protocol(format!("invalid {name} header: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subagent_parent_headers_are_an_atomic_pair() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-parent-request-id",
|
||||
HeaderValue::from_static("parent-run"),
|
||||
);
|
||||
assert!(parent_headers(&headers).is_err());
|
||||
|
||||
headers.insert(
|
||||
"x-parent-agent-tool-call-id",
|
||||
HeaderValue::from_static("parent-call"),
|
||||
);
|
||||
assert_eq!(
|
||||
parent_headers(&headers).unwrap(),
|
||||
Some(CursorParent {
|
||||
run_id: "parent-run".into(),
|
||||
tool_call_id: "parent-call".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OrderedInbox<T> {
|
||||
next: i64,
|
||||
pending: BTreeMap<i64, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for OrderedInbox<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next: 0,
|
||||
pending: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> OrderedInbox<T> {
|
||||
pub fn starting_at(next: i64) -> Self {
|
||||
Self {
|
||||
next,
|
||||
pending: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, seqno: i64, value: T) -> Vec<(i64, T)> {
|
||||
if seqno < self.next {
|
||||
return Vec::new();
|
||||
}
|
||||
self.pending.entry(seqno).or_insert(value);
|
||||
let mut ready = Vec::new();
|
||||
while let Some(value) = self.pending.remove(&self.next) {
|
||||
ready.push((self.next, value));
|
||||
self.next += 1;
|
||||
}
|
||||
ready
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
mod query;
|
||||
mod render;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ToolCall, Usage},
|
||||
provider::ModelEvent,
|
||||
Result,
|
||||
};
|
||||
|
||||
pub use query::tool_query;
|
||||
pub(crate) use render::{edit_content_delta, edit_path_partial};
|
||||
pub use render::{render_tool_call, tool_completed, tool_placeholder, tool_started};
|
||||
|
||||
pub fn response_event(
|
||||
event: &ModelEvent,
|
||||
model_call_id: &str,
|
||||
) -> Result<Option<pb::AgentServerMessage>> {
|
||||
use pb::interaction_update::Message;
|
||||
let message = match event {
|
||||
ModelEvent::TextDelta(text) => Message::TextDelta(pb::TextDeltaUpdate {
|
||||
text: text.clone(),
|
||||
is_server_notice: false,
|
||||
}),
|
||||
ModelEvent::ThinkingDelta(text) => Message::ThinkingDelta(pb::ThinkingDeltaUpdate {
|
||||
text: text.clone(),
|
||||
thinking_style: Some(pb::ThinkingStyle::Default as i32),
|
||||
}),
|
||||
ModelEvent::ToolCallStart { call_id, name, .. } => {
|
||||
Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
call_id: call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(name, call_id)?),
|
||||
args_text_delta: String::new(),
|
||||
model_call_id: model_call_id.into(),
|
||||
})
|
||||
}
|
||||
ModelEvent::ToolCallArgumentsDelta { .. } => return Ok(None),
|
||||
ModelEvent::ToolCallEnd { .. }
|
||||
| ModelEvent::Start { .. }
|
||||
| ModelEvent::TextStart
|
||||
| ModelEvent::TextEnd
|
||||
| ModelEvent::ThinkingStart
|
||||
| ModelEvent::ThinkingEnd
|
||||
| ModelEvent::ProviderReplayState(_)
|
||||
| ModelEvent::Usage(_)
|
||||
| ModelEvent::Done(_) => return Ok(None),
|
||||
};
|
||||
Ok(Some(server_interaction(message)))
|
||||
}
|
||||
|
||||
pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
|
||||
let milliseconds = elapsed.as_millis().clamp(1, i32::MAX as u128) as i32;
|
||||
server_interaction(pb::interaction_update::Message::ThinkingCompleted(
|
||||
pb::ThinkingCompletedUpdate {
|
||||
thinking_duration_ms: milliseconds,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_interaction(
|
||||
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(&call.name, &call.call_id)?),
|
||||
args_text_delta: delta.into(),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn turn_ended(usage: Option<Usage>) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::TurnEnded(
|
||||
pb::TurnEndedUpdate {
|
||||
input_tokens: usage.and_then(|usage| usage.input_tokens.map(|value| value as i64)),
|
||||
output_tokens: usage.and_then(|usage| usage.output_tokens.map(|value| value as i64)),
|
||||
cache_read_tokens: usage
|
||||
.and_then(|usage| usage.cache_read_tokens.map(|value| value as i64)),
|
||||
cache_write_tokens: usage
|
||||
.and_then(|usage| usage.cache_write_tokens.map(|value| value as i64)),
|
||||
reasoning_tokens: usage
|
||||
.and_then(|usage| usage.reasoning_tokens.map(|value| value as i64)),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn token_delta(tokens: u64) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::TokenDelta(
|
||||
pb::TokenDeltaUpdate {
|
||||
tokens: tokens.min(i32::MAX as u64) as i32,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn server_interaction(message: pb::interaction_update::Message) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionUpdate(
|
||||
pb::InteractionUpdate {
|
||||
message: Some(message),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
|
||||
|
||||
pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
use pb::interaction_query::Query;
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
};
|
||||
let optional_string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let query = match normalized(&call.name).as_str() {
|
||||
"askquestion" => {
|
||||
let questions = call
|
||||
.arguments
|
||||
.get("questions")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|question| -> Result<_> {
|
||||
let required = |name: &str| {
|
||||
question
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("question is missing {name}")))
|
||||
};
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|option| -> Result<_> {
|
||||
let value = |name: &str| {
|
||||
option
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"question option is missing {name}"
|
||||
))
|
||||
})
|
||||
};
|
||||
Ok(pb::ask_question_args::Option {
|
||||
id: value("id")?,
|
||||
label: value("label")?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(pb::ask_question_args::Question {
|
||||
id: required("id")?,
|
||||
prompt: required("prompt")?,
|
||||
options,
|
||||
allow_multiple: question
|
||||
.get("allow_multiple")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Query::AskQuestionInteractionQuery(pb::AskQuestionInteractionQuery {
|
||||
args: Some(pb::AskQuestionArgs {
|
||||
title: optional_string("title").unwrap_or_default(),
|
||||
questions,
|
||||
run_async: false,
|
||||
async_original_tool_call_id: String::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
|
||||
args: Some(pb::WebSearchArgs {
|
||||
search_term: string("search_term")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"webfetch" => Query::WebFetchRequestQuery(pb::WebFetchRequestQuery {
|
||||
args: Some(pb::WebFetchArgs {
|
||||
url: string("url")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
skip_approval: false,
|
||||
smart_mode_approval: smart_mode_approval(
|
||||
call,
|
||||
"requestSmartModeApproval",
|
||||
"smartModeBlockReason",
|
||||
)?,
|
||||
}),
|
||||
"switchmode" => Query::SwitchModeRequestQuery(pb::SwitchModeRequestQuery {
|
||||
args: Some(pb::SwitchModeArgs {
|
||||
target_mode_id: string("target_mode_id")?,
|
||||
explanation: optional_string("explanation"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"createplan" => {
|
||||
let todos = call
|
||||
.arguments
|
||||
.get("todos")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|todo| pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
status: pb::TodoStatus::Pending as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
Query::CreatePlanRequestQuery(pb::CreatePlanRequestQuery {
|
||||
args: Some(pb::CreatePlanArgs {
|
||||
plan: string("plan")?,
|
||||
todos,
|
||||
overview: string("overview")?,
|
||||
name: string("name")?,
|
||||
is_project: false,
|
||||
phases: Vec::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"generateimage" => Query::GenerateImageRequestQuery(pb::GenerateImageRequestQuery {
|
||||
args: Some(pb::GenerateImageArgs {
|
||||
description: string("description")?,
|
||||
file_path: optional_string("filename"),
|
||||
reference_image_paths: call
|
||||
.arguments
|
||||
.get("reference_image_paths")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
aspect_ratio: optional_string("aspect_ratio"),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"tool {other} is not an InteractionQuery"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionQuery(
|
||||
pb::InteractionQuery {
|
||||
id,
|
||||
query: Some(query),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_mode_approval(
|
||||
call: &ToolCall,
|
||||
request_field: &str,
|
||||
reason_field: &str,
|
||||
) -> Result<Option<pb::SmartModeApproval>> {
|
||||
if !call
|
||||
.arguments
|
||||
.get(request_field)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let reason = call
|
||||
.arguments
|
||||
.get(reason_field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} requires {reason_field}", call.name)))?;
|
||||
Ok(Some(pb::SmartModeApproval {
|
||||
request_id: call.call_id.clone(),
|
||||
reason: reason.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalized(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
proto::agent::v1 as pb,
|
||||
tools::{
|
||||
codec, edit,
|
||||
result::{self as tool_result, ToolCompletion},
|
||||
},
|
||||
},
|
||||
model::ToolCall,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::server_interaction;
|
||||
|
||||
pub(crate) fn edit_path_partial(call: &ToolCall, path: &str) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::PartialToolCall(
|
||||
pb::PartialToolCallUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(pb::ToolCall {
|
||||
hook_additional_contexts: Vec::new(),
|
||||
tool_call_id: Some(call.call_id.clone()),
|
||||
started_at_ms: None,
|
||||
completed_at_ms: None,
|
||||
tool: Some(pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
||||
args: Some(pb::EditArgs {
|
||||
path: path.into(),
|
||||
stream_content: None,
|
||||
}),
|
||||
result: None,
|
||||
})),
|
||||
}),
|
||||
args_text_delta: String::new(),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn edit_content_delta(call: &ToolCall, content: String) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::ToolCallDelta(Box::new(
|
||||
pb::ToolCallDeltaUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call_delta: Some(Box::new(pb::ToolCallDelta {
|
||||
delta: Some(pb::tool_call_delta::Delta::EditToolCallDelta(
|
||||
pb::EditToolCallDelta {
|
||||
stream_content_delta: content,
|
||||
},
|
||||
)),
|
||||
})),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn tool_started(call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_interaction(
|
||||
pb::interaction_update::Message::ToolCallStarted(pb::ToolCallStartedUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(render_tool_call(call, false)?),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn tool_completed(call: &ToolCall, completion: &ToolCompletion) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::ToolCallCompleted(
|
||||
pb::ToolCallCompletedUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(completion.tool_call().clone()),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||
use pb::tool_call::Tool;
|
||||
let tool = match normalized(name).as_str() {
|
||||
"shell" => Tool::ShellToolCall(pb::ShellToolCall::default()),
|
||||
"delete" => Tool::DeleteToolCall(pb::DeleteToolCall::default()),
|
||||
"glob" => Tool::GlobToolCall(pb::GlobToolCall::default()),
|
||||
"grep" => Tool::GrepToolCall(pb::GrepToolCall::default()),
|
||||
"read" => Tool::ReadToolCall(pb::ReadToolCall::default()),
|
||||
"todowrite" => Tool::UpdateTodosToolCall(pb::UpdateTodosToolCall::default()),
|
||||
"strreplace" | "editnotebook" | "write" => Tool::EditToolCall(pb::EditToolCall::default()),
|
||||
"readlints" => Tool::ReadLintsToolCall(pb::ReadLintsToolCall::default()),
|
||||
"callmcptool" => Tool::McpToolCall(pb::McpToolCall::default()),
|
||||
"createplan" => Tool::CreatePlanToolCall(pb::CreatePlanToolCall::default()),
|
||||
"websearch" => Tool::WebSearchToolCall(pb::WebSearchToolCall::default()),
|
||||
"task" => Tool::TaskToolCall(pb::TaskToolCall::default()),
|
||||
"fetchmcpresource" => Tool::ReadMcpResourceToolCall(pb::ReadMcpResourceToolCall::default()),
|
||||
"askquestion" => Tool::AskQuestionToolCall(pb::AskQuestionToolCall::default()),
|
||||
"webfetch" => Tool::WebFetchToolCall(pb::WebFetchToolCall::default()),
|
||||
"switchmode" => Tool::SwitchModeToolCall(pb::SwitchModeToolCall::default()),
|
||||
"generateimage" => Tool::GenerateImageToolCall(pb::GenerateImageToolCall::default()),
|
||||
"updatecurrentstep" => {
|
||||
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
||||
}
|
||||
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
|
||||
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
||||
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
||||
};
|
||||
Ok(pb::ToolCall {
|
||||
hook_additional_contexts: Vec::new(),
|
||||
tool_call_id: Some(call_id.into()),
|
||||
started_at_ms: None,
|
||||
completed_at_ms: None,
|
||||
tool: Some(tool),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall> {
|
||||
let mut output = tool_placeholder(&call.name, &call.call_id)?;
|
||||
let timestamp = now_ms();
|
||||
output.started_at_ms = Some(timestamp);
|
||||
if completed {
|
||||
output.completed_at_ms = Some(timestamp);
|
||||
}
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
let optional = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
match output.tool.as_mut() {
|
||||
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
|
||||
tool.args = Some(pb::ShellArgs {
|
||||
command: string("command"),
|
||||
working_directory: optional("working_directory").unwrap_or_default(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
|
||||
tool.args = Some(pb::DeleteArgs {
|
||||
path: string("path"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GlobToolCall(tool)) => {
|
||||
tool.args = Some(pb::GlobToolArgs {
|
||||
target_directory: optional("target_directory"),
|
||||
glob_pattern: string("glob_pattern"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GrepToolCall(tool)) => {
|
||||
tool.args = Some(pb::GrepArgs {
|
||||
pattern: string("pattern"),
|
||||
path: optional("path"),
|
||||
glob: optional("glob"),
|
||||
output_mode: optional("output_mode"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
|
||||
tool.args = Some(pb::ReadToolArgs {
|
||||
path: string("path"),
|
||||
offset: call
|
||||
.arguments
|
||||
.get("offset")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|value| value as i32),
|
||||
limit: call
|
||||
.arguments
|
||||
.get("limit")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|value| value as i32),
|
||||
include_line_numbers: call
|
||||
.arguments
|
||||
.get("include_line_numbers")
|
||||
.and_then(Value::as_bool),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::UpdateTodosToolCall(tool)) => {
|
||||
tool.args = Some(pb::UpdateTodosArgs {
|
||||
todos: tool_result::todo_items(&call.arguments),
|
||||
merge: call
|
||||
.arguments
|
||||
.get("merge")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
|
||||
let stream_content = if normalized(&call.name) == "write" {
|
||||
optional("contents").unwrap_or_default()
|
||||
} else {
|
||||
optional("new_string").unwrap_or_default()
|
||||
};
|
||||
tool.args = Some(pb::EditArgs {
|
||||
path: if normalized(&call.name) == "editnotebook" {
|
||||
string("target_notebook")
|
||||
} else {
|
||||
string("path")
|
||||
},
|
||||
stream_content: Some(edit::normalize_newlines(&stream_content)),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::ReadLintsToolCall(tool)) => {
|
||||
tool.args = Some(pb::ReadLintsToolArgs {
|
||||
paths: call
|
||||
.arguments
|
||||
.get("paths")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::McpToolCall(tool)) => {
|
||||
tool.args = Some(pb::McpArgs {
|
||||
name: optional("toolName").unwrap_or_default(),
|
||||
args: call
|
||||
.arguments
|
||||
.get("arguments")
|
||||
.and_then(Value::as_object)
|
||||
.map(codec::json_object_to_prost)
|
||||
.unwrap_or_default(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
tool_name: optional("toolName").unwrap_or_default(),
|
||||
server_identifier: string("server"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::CreatePlanToolCall(tool)) => {
|
||||
tool.args = Some(pb::CreatePlanArgs {
|
||||
plan: string("plan"),
|
||||
todos: tool_result::todo_items(&call.arguments),
|
||||
overview: string("overview"),
|
||||
name: string("name"),
|
||||
is_project: false,
|
||||
phases: Vec::new(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
|
||||
tool.args = Some(pb::WebSearchArgs {
|
||||
search_term: string("search_term"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::TaskToolCall(tool)) => {
|
||||
tool.args = Some(pb::TaskArgs {
|
||||
description: string("description"),
|
||||
prompt: string("prompt"),
|
||||
subagent_type: Some(subagent_type(&string("subagent_type"))),
|
||||
model: optional("model"),
|
||||
resume: optional("resume"),
|
||||
agent_id: None,
|
||||
attachments: call
|
||||
.arguments
|
||||
.get("file_attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
mode: 0,
|
||||
responding_to_message_ids: Vec::new(),
|
||||
environment: execution_environment(optional("environment").as_deref()),
|
||||
machine: None,
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::ReadMcpResourceToolCall(tool)) => {
|
||||
tool.args = Some(pb::ReadMcpResourceExecArgs {
|
||||
server: string("server"),
|
||||
uri: string("uri"),
|
||||
download_path: optional("downloadPath"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
smart_mode_approval: None,
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::WebFetchToolCall(tool)) => {
|
||||
tool.args = Some(pb::WebFetchArgs {
|
||||
url: string("url"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::SwitchModeToolCall(tool)) => {
|
||||
tool.args = Some(pb::SwitchModeArgs {
|
||||
target_mode_id: string("target_mode_id"),
|
||||
explanation: optional("explanation"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GenerateImageToolCall(tool)) => {
|
||||
tool.args = Some(pb::GenerateImageArgs {
|
||||
description: string("description"),
|
||||
file_path: optional("filename"),
|
||||
reference_image_paths: call
|
||||
.arguments
|
||||
.get("reference_image_paths")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
aspect_ratio: optional("aspect_ratio"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::CommunicateUpdateToolCall(tool)) => {
|
||||
tool.args = Some(pb::CommunicateUpdateArgs {
|
||||
current_step: optional("current_step"),
|
||||
final_summary: optional("final_summary"),
|
||||
completed_subtitle: optional("completed_subtitle"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::WriteShellStdinToolCall(tool)) => {
|
||||
tool.args = Some(pb::WriteShellStdinArgs {
|
||||
shell_id: call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default() as u32,
|
||||
chars: string("chars"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
|
||||
tool.args = Some(pb::AwaitArgs {
|
||||
task_id: string("shell_id"),
|
||||
block_until_ms: call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
regex: optional("pattern"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
||||
tool.args = Some(pb::GetMcpToolsArgs {
|
||||
server: optional("server"),
|
||||
tool_name: optional("toolName"),
|
||||
pattern: optional("pattern"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn subagent_type(name: &str) -> pb::SubagentType {
|
||||
use pb::subagent_type::Type;
|
||||
let r#type = match name.to_ascii_lowercase().as_str() {
|
||||
"" | "generalpurpose" => Type::Unspecified(pb::SubagentTypeUnspecified {}),
|
||||
"explore" => Type::Explore(pb::SubagentTypeExplore {}),
|
||||
"browser-use" | "browseruse" => Type::BrowserUse(pb::SubagentTypeBrowserUse {}),
|
||||
"shell" => Type::Shell(pb::SubagentTypeShell {}),
|
||||
"bash" => Type::Bash(pb::SubagentTypeBash {}),
|
||||
"debug" => Type::Debug(pb::SubagentTypeDebug {}),
|
||||
"cursor-guide" | "cursorguide" => Type::CursorGuide(pb::SubagentTypeCursorGuide {}),
|
||||
"computer-use" | "computeruse" => Type::ComputerUse(pb::SubagentTypeComputerUse {}),
|
||||
_ => Type::Custom(pb::SubagentTypeCustom { name: name.into() }),
|
||||
};
|
||||
pb::SubagentType {
|
||||
r#type: Some(r#type),
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_environment(value: Option<&str>) -> i32 {
|
||||
match value {
|
||||
Some("cloud") => pb::SubagentExecutionEnvironment::Cloud as i32,
|
||||
Some("local") | None => pb::SubagentExecutionEnvironment::Local as i32,
|
||||
Some(_) => pb::SubagentExecutionEnvironment::Unspecified as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum StringFieldEvent {
|
||||
Delta { name: String, text: String },
|
||||
End { name: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct JsonStringFields {
|
||||
state: State,
|
||||
key: String,
|
||||
string: JsonString,
|
||||
skipped: SkippedValue,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum State {
|
||||
#[default]
|
||||
Object,
|
||||
Key,
|
||||
KeyString,
|
||||
Colon,
|
||||
Value,
|
||||
ValueString,
|
||||
SkipValue,
|
||||
AfterValue,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl JsonStringFields {
|
||||
pub fn push(&mut self, input: &str) -> Result<Vec<StringFieldEvent>> {
|
||||
let mut events = Vec::new();
|
||||
for character in input.chars() {
|
||||
self.consume(character, &mut events)?;
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn consume(&mut self, character: char, events: &mut Vec<StringFieldEvent>) -> Result<()> {
|
||||
match self.state {
|
||||
State::Object => match character {
|
||||
'{' => self.state = State::Key,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("tool arguments must start with an object")),
|
||||
},
|
||||
State::Key => match character {
|
||||
'"' => {
|
||||
self.key.clear();
|
||||
self.string.clear();
|
||||
self.state = State::KeyString;
|
||||
}
|
||||
'}' => self.state = State::Done,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected a tool argument name")),
|
||||
},
|
||||
State::KeyString => match self.string.push(character)? {
|
||||
StringStep::Text(text) => self.key.push_str(&text),
|
||||
StringStep::End => self.state = State::Colon,
|
||||
StringStep::Pending => {}
|
||||
},
|
||||
State::Colon => match character {
|
||||
':' => self.state = State::Value,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected ':' after tool argument name")),
|
||||
},
|
||||
State::Value => match character {
|
||||
'"' => {
|
||||
self.string.clear();
|
||||
self.state = State::ValueString;
|
||||
}
|
||||
value if value.is_whitespace() => {}
|
||||
value => {
|
||||
self.skipped.start(value);
|
||||
self.state = State::SkipValue;
|
||||
}
|
||||
},
|
||||
State::ValueString => match self.string.push(character)? {
|
||||
StringStep::Text(text) => push_delta(events, &self.key, text),
|
||||
StringStep::End => {
|
||||
events.push(StringFieldEvent::End {
|
||||
name: self.key.clone(),
|
||||
});
|
||||
self.state = State::AfterValue;
|
||||
}
|
||||
StringStep::Pending => {}
|
||||
},
|
||||
State::SkipValue => {
|
||||
if let Some(terminal) = self.skipped.push(character) {
|
||||
self.state = match terminal {
|
||||
',' => State::Key,
|
||||
'}' => State::Done,
|
||||
_ => return Err(protocol("invalid skipped JSON value terminator")),
|
||||
};
|
||||
}
|
||||
}
|
||||
State::AfterValue => match character {
|
||||
',' => self.state = State::Key,
|
||||
'}' => self.state = State::Done,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected ',' after tool argument value")),
|
||||
},
|
||||
State::Done if character.is_whitespace() => {}
|
||||
State::Done => return Err(protocol("data after tool arguments object")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delta(events: &mut Vec<StringFieldEvent>, name: &str, text: String) {
|
||||
if let Some(StringFieldEvent::Delta {
|
||||
name: previous_name,
|
||||
text: previous_text,
|
||||
}) = events.last_mut()
|
||||
{
|
||||
if previous_name == name {
|
||||
previous_text.push_str(&text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
events.push(StringFieldEvent::Delta {
|
||||
name: name.into(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsonString {
|
||||
escape: String,
|
||||
}
|
||||
|
||||
enum StringStep {
|
||||
Text(String),
|
||||
End,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl JsonString {
|
||||
fn clear(&mut self) {
|
||||
self.escape.clear();
|
||||
}
|
||||
|
||||
fn push(&mut self, character: char) -> Result<StringStep> {
|
||||
if self.escape.is_empty() {
|
||||
return match character {
|
||||
'"' => Ok(StringStep::End),
|
||||
'\\' => {
|
||||
self.escape.push(character);
|
||||
Ok(StringStep::Pending)
|
||||
}
|
||||
value if value < '\u{20}' => Err(protocol("control character in JSON string")),
|
||||
value => Ok(StringStep::Text(value.to_string())),
|
||||
};
|
||||
}
|
||||
|
||||
self.escape.push(character);
|
||||
let complete = match self.escape.as_bytes() {
|
||||
[b'\\', b'u', a, b, c, d]
|
||||
if [a, b, c, d].iter().all(|value| value.is_ascii_hexdigit()) =>
|
||||
{
|
||||
let code = u16::from_str_radix(&self.escape[2..], 16)
|
||||
.map_err(|_| protocol("invalid JSON unicode escape"))?;
|
||||
!(0xD800..=0xDBFF).contains(&code)
|
||||
}
|
||||
[b'\\', b'u', ..] if self.escape.len() < 6 => false,
|
||||
[b'\\', b'u', a, b, c, d, b'\\', b'u', e, f, g, h]
|
||||
if [a, b, c, d, e, f, g, h]
|
||||
.iter()
|
||||
.all(|value| value.is_ascii_hexdigit()) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
[b'\\', b'u', ..] if self.escape.len() < 12 => false,
|
||||
[b'\\', b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't'] => true,
|
||||
[b'\\'] => false,
|
||||
_ => return Err(protocol("invalid JSON string escape")),
|
||||
};
|
||||
if !complete {
|
||||
return Ok(StringStep::Pending);
|
||||
}
|
||||
let quoted = format!("\"{}\"", self.escape);
|
||||
let decoded: String = serde_json::from_str("ed)
|
||||
.map_err(|error| protocol(&format!("invalid JSON string escape: {error}")))?;
|
||||
self.escape.clear();
|
||||
Ok(StringStep::Text(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SkippedValue {
|
||||
depth: usize,
|
||||
string: bool,
|
||||
escaped: bool,
|
||||
}
|
||||
|
||||
impl SkippedValue {
|
||||
fn start(&mut self, first: char) {
|
||||
*self = Self::default();
|
||||
self.observe(first);
|
||||
}
|
||||
|
||||
fn push(&mut self, character: char) -> Option<char> {
|
||||
if !self.string && self.depth == 0 && matches!(character, ',' | '}') {
|
||||
return Some(character);
|
||||
}
|
||||
self.observe(character);
|
||||
None
|
||||
}
|
||||
|
||||
fn observe(&mut self, character: char) {
|
||||
if self.string {
|
||||
if self.escaped {
|
||||
self.escaped = false;
|
||||
} else if character == '\\' {
|
||||
self.escaped = true;
|
||||
} else if character == '"' {
|
||||
self.string = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match character {
|
||||
'"' => self.string = true,
|
||||
'{' | '[' => self.depth += 1,
|
||||
'}' | ']' => self.depth = self.depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol(message: &str) -> Error {
|
||||
Error::Protocol(message.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn streams_top_level_strings_and_decodes_split_escapes() {
|
||||
let mut fields = JsonStringFields::default();
|
||||
let mut events = fields
|
||||
.push("{\"path\":\"/tmp/a\",\"count\":1,\"contents\":\"a\\n\\uD8")
|
||||
.unwrap();
|
||||
events.extend(fields.push("3D\\uDE00b\"}").unwrap());
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
StringFieldEvent::Delta {
|
||||
name: "path".into(),
|
||||
text: "/tmp/a".into()
|
||||
},
|
||||
StringFieldEvent::End {
|
||||
name: "path".into()
|
||||
},
|
||||
StringFieldEvent::Delta {
|
||||
name: "contents".into(),
|
||||
text: "a\n".into()
|
||||
},
|
||||
StringFieldEvent::Delta {
|
||||
name: "contents".into(),
|
||||
text: "😀b".into()
|
||||
},
|
||||
StringFieldEvent::End {
|
||||
name: "contents".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::CursorSessionHandle,
|
||||
cursor::{
|
||||
connect::{
|
||||
encode_end_stream, encode_error_end_stream, ConnectCode, ConnectErrorDetail,
|
||||
ConnectStreamError,
|
||||
},
|
||||
proto::aiserver::v1 as ai,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub fn finish_success(handle: &CursorSessionHandle) {
|
||||
handle.emit_frame(encode_end_stream());
|
||||
handle.close_output();
|
||||
}
|
||||
|
||||
pub fn fail(handle: &CursorSessionHandle, error: &Error) -> Result<()> {
|
||||
let stream_error = match error {
|
||||
Error::Provider(_) | Error::Http(_) => provider_error(error),
|
||||
Error::Protocol(_) | Error::Decode(_) | Error::Json(_) => {
|
||||
plain_error(ConnectCode::InvalidArgument, error)
|
||||
}
|
||||
Error::RunNotFound(_) => plain_error(ConnectCode::NotFound, error),
|
||||
Error::Cancelled => plain_error(ConnectCode::Canceled, error),
|
||||
Error::Config(_)
|
||||
| Error::Store(_)
|
||||
| Error::Database(_)
|
||||
| Error::Migration(_)
|
||||
| Error::Encode(_)
|
||||
| Error::Io(_) => plain_error(ConnectCode::Internal, error),
|
||||
};
|
||||
handle.emit_frame(encode_error_end_stream(&stream_error)?);
|
||||
handle.close_output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cancel(handle: &CursorSessionHandle) -> Result<()> {
|
||||
handle.emit_frame(encode_error_end_stream(&ConnectStreamError {
|
||||
code: ConnectCode::Canceled,
|
||||
message: "run was cancelled".into(),
|
||||
details: Vec::new(),
|
||||
})?);
|
||||
handle.close_output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plain_error(code: ConnectCode, error: &Error) -> ConnectStreamError {
|
||||
ConnectStreamError {
|
||||
code,
|
||||
message: error.to_string(),
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_error(error: &Error) -> ConnectStreamError {
|
||||
let detail = ai::ErrorDetails {
|
||||
error: ai::error_details::Error::ProviderError as i32,
|
||||
details: Some(ai::CustomErrorDetails {
|
||||
title: "Server Error".into(),
|
||||
detail: error.to_string(),
|
||||
allow_command_links_potentially_unsafe_please_only_use_for_handwritten_trusted_markdown:
|
||||
Some(true),
|
||||
is_retryable: Some(true),
|
||||
show_request_id: Some(true),
|
||||
should_show_immediate_error: Some(false),
|
||||
}),
|
||||
is_expected: Some(false),
|
||||
};
|
||||
ConnectStreamError {
|
||||
code: ConnectCode::Unavailable,
|
||||
message: error.to_string(),
|
||||
details: vec![ConnectErrorDetail {
|
||||
type_name: "aiserver.v1.ErrorDetails".into(),
|
||||
value: STANDARD_NO_PAD.encode(detail.encode_to_vec()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
mod actor;
|
||||
pub mod bidi_append;
|
||||
pub mod blob_sync;
|
||||
pub mod checkpoint;
|
||||
pub mod connect;
|
||||
pub mod handlers;
|
||||
mod inbox;
|
||||
pub mod interaction;
|
||||
mod json_stream;
|
||||
pub(crate) mod lifecycle;
|
||||
mod model_catalog;
|
||||
mod presentation;
|
||||
mod projection;
|
||||
pub mod prompting;
|
||||
pub mod proto;
|
||||
pub mod proxy;
|
||||
pub mod request;
|
||||
pub mod run_sse;
|
||||
pub mod session;
|
||||
pub mod sessions;
|
||||
pub mod tools;
|
||||
mod usage;
|
||||
|
||||
pub use command::CursorCommand;
|
||||
pub use sessions::{CursorParent, CursorSessionHandle, CursorSessionRegistry};
|
||||
mod command;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user