feat(console): add initial console for Cursor BYOK with provider management and LLM call tracking

This commit is contained in:
leookun
2026-08-17 10:14:10 +08:00
parent 4db2061611
commit c96cb0a110
220 changed files with 29466 additions and 10769 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cursor BYOK</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2533
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"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"
}
}
+38
View File
@@ -0,0 +1,38 @@
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 }),
}),
}
+61
View File
@@ -0,0 +1,61 @@
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 }[]
}
+48
View File
@@ -0,0 +1,48 @@
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>
)
}
@@ -0,0 +1,28 @@
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>
}
+17
View File
@@ -0,0 +1,17 @@
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>
}
@@ -0,0 +1,46 @@
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>
}
@@ -0,0 +1,38 @@
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>
}
@@ -0,0 +1,65 @@
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>
)
}
@@ -0,0 +1,15 @@
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>
}
+19
View File
@@ -0,0 +1,19 @@
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>,
)
+17
View File
@@ -0,0 +1,17 @@
@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; }
}
+20
View File
@@ -0,0 +1,20 @@
{
"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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
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' },
},
})