mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
feat(console): add initial console for Cursor BYOK with provider management and LLM call tracking
This commit is contained in:
@@ -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 }),
|
||||
}),
|
||||
}
|
||||
@@ -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 }[]
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user