Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export type Overview = {
access?: {
openai_base_url?: string
chat_completions?: string
messages?: string
responses?: string
models?: string
health?: string
}
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/EndpointList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react'
import { Button, Card, Chip } from '@heroui/react'
import { ArrowSquareOut, BracketsCurly, Check, Copy, Heartbeat, ListBullets, PaperPlaneTilt } from '@phosphor-icons/react'
import type { Overview } from '@/api/types'
import { useI18n } from '@/hooks/useI18n'
import { absUrl } from '@/lib/url'

type EndpointListProps = {
access?: Overview['access']
}

export function EndpointList({ access }: EndpointListProps) {
const { t } = useI18n()
const [copiedEndpoint, setCopiedEndpoint] = useState('')
const base = absUrl(access?.openai_base_url || '/v1')
const endpoints = useMemo(() => [
{ name: t('endpointOpenAI'), url: base, method: 'BASE', hint: t('endpointBaseHint'), icon: <BracketsCurly size={17} /> },
{ name: t('endpointChat'), url: absUrl(access?.chat_completions || `${base}/chat/completions`), method: 'POST', hint: t('endpointChatHint'), icon: <PaperPlaneTilt size={17} /> },
{ name: t('endpointMessages'), url: absUrl(access?.messages || `${base}/messages`), method: 'POST', hint: t('endpointMessagesHint'), icon: <PaperPlaneTilt size={17} /> },
{ name: t('endpointResponses'), url: absUrl(access?.responses || `${base}/responses`), method: 'POST', hint: t('endpointResponsesHint'), icon: <PaperPlaneTilt size={17} /> },
{ name: t('endpointModels'), url: absUrl(access?.models || `${base}/models`), method: 'GET', hint: t('endpointModelsHint'), icon: <ListBullets size={17} /> },
{ name: t('endpointHealth'), url: absUrl(access?.health || '/health'), method: 'GET', hint: t('endpointHealthHint'), icon: <Heartbeat size={17} /> },
], [access, base, t])

return (
<Card data-gsap-reveal className="overflow-hidden p-0">
<div className="flex items-start justify-between gap-4 border-b border-separator px-5 py-5 sm:px-6">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold tracking-[-0.015em]">{t('endpoints')}</h3>
<Chip size="sm" variant="soft">{t('endpointCount', { count: endpoints.length - 1 })}</Chip>
</div>
<p className="mt-1 text-xs leading-5 text-muted">{t('routesHint')}</p>
</div>
<div className="hidden items-center gap-2 text-xs text-muted sm:flex">
<span className="status-dot" data-state="ok" />
{t('endpointReady')}
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5">
{endpoints.map((item) => (
<div key={item.name} className="group min-w-0 rounded-2xl border border-border bg-surface-secondary/35 p-4 transition-colors hover:border-foreground/20">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface text-muted">{item.icon}</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{item.name}</div>
<code className="mono mt-1 block truncate text-[11px] text-muted">{item.url}</code>
</div>
</div>
<Chip size="sm" variant="soft">{item.method}</Chip>
</div>
<p className="mt-3 min-h-10 text-xs leading-5 text-muted">{item.hint}</p>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-separator pt-3">
<span className="text-[10px] font-medium text-muted">{item.method === 'BASE' ? t('endpointBaseLabel') : t('endpointAuthLabel')}</span>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void navigator.clipboard.writeText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
{copiedEndpoint === item.name ? <Check size={14} className="text-success" /> : <Copy size={14} />}
</Button>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('open')} onPress={() => window.open(item.url, '_blank', 'noopener,noreferrer')}>
<ArrowSquareOut size={14} />
</Button>
</div>
</div>
</div>
))}
</div>
</Card>
)
}
28 changes: 26 additions & 2 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const messages: Record<Lang, Dict> = {
develop: 'Develop',
system: 'System',
runtimeSnapshot: 'Runtime and scheduler snapshot',
routesHint: 'OpenAI-compatible routes',
routesHint: 'OpenAI / Anthropic-compatible routes',
clientConfig: 'Client configuration',
clientConfigHint: 'Use the same console key as Bearer token',
authentication: 'Authentication',
Expand Down Expand Up @@ -305,8 +305,20 @@ export const messages: Record<Lang, Dict> = {
requesting: 'Requesting…',
endpointOpenAI: 'OpenAI Compatible',
endpointChat: 'Chat Completions',
endpointBaseHint: 'Use this path as the base URL in OpenAI-compatible clients.',
endpointChatHint: 'OpenAI-compatible chat requests with streaming, images, and tools.',
endpointMessages: 'Anthropic Messages',
endpointMessagesHint: 'Stateless Anthropic Messages adapter with text, images, and tools.',
endpointResponses: 'OpenAI Responses',
endpointResponsesHint: 'Stateless OpenAI Responses adapter with text, images, and tools.',
endpointModels: 'Models',
endpointModelsHint: 'List the public model IDs currently available through the proxy.',
endpointHealth: 'Health',
endpointHealthHint: 'Check whether the local proxy is responding.',
endpointCount: '{count} API routes',
endpointReady: 'Available now',
endpointBaseLabel: 'Client base URL',
endpointAuthLabel: 'Bearer API key',
hasUserBlob: 'Has user blob',
userBlobBytes: 'User blob bytes',
machineId: 'Machine ID',
Expand Down Expand Up @@ -542,7 +554,7 @@ export const messages: Record<Lang, Dict> = {
develop: '开发',
system: '系统',
runtimeSnapshot: '运行时与调度器快照',
routesHint: 'OpenAI 兼容端点',
routesHint: 'OpenAI / Anthropic 兼容端点',
clientConfig: '客户端配置',
clientConfigHint: '使用与控制台相同的密钥作为 Bearer Token',
authentication: '认证方式',
Expand Down Expand Up @@ -837,8 +849,20 @@ export const messages: Record<Lang, Dict> = {
requesting: '请求中…',
endpointOpenAI: 'OpenAI 兼容',
endpointChat: 'Chat Completions',
endpointBaseHint: '在 OpenAI 兼容客户端中,将此地址作为 Base URL。',
endpointChatHint: '支持流式、图片与工具调用的 OpenAI 兼容对话接口。',
endpointMessages: 'Anthropic Messages',
endpointMessagesHint: '无状态 Anthropic Messages 适配层,支持文本、图片和工具调用。',
endpointResponses: 'OpenAI Responses',
endpointResponsesHint: '无状态 OpenAI Responses 适配层,支持文本、图片和工具调用。',
endpointModels: '模型列表',
endpointModelsHint: '查看当前代理可用的公开模型 ID。',
endpointHealth: '健康检查',
endpointHealthHint: '检查本地代理是否正在响应。',
endpointCount: '{count} 个 API 端点',
endpointReady: '当前可用',
endpointBaseLabel: '客户端 Base URL',
endpointAuthLabel: 'Bearer API Key',
hasUserBlob: '已有 user blob',
userBlobBytes: 'user blob 大小',
machineId: 'Machine ID',
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { PageAlert } from '@/components/ui/PageAlert'
import { AccessPageSkeleton } from '@/components/ui/PageSkeletons'
import { ProviderMark } from '@/components/ProviderMark'
import { accountProviderLabel } from '@/lib/provider'
import { EndpointList } from '@/components/EndpointList'

type RequestState = 'idle' | 'loading' | 'success' | 'error'

Expand Down Expand Up @@ -100,6 +101,8 @@ export function AccessPage() {
const poolModels = overview?.models || []
const accounts = overview?.accounts || []
const base = absUrl(overview?.access?.openai_base_url || '/v1')
const chatPath = overview?.access?.chat_completions || '/v1/chat/completions'
const chatEndpoint = absUrl(chatPath)
const [model, setModel] = useState('')
const [accountId, setAccountId] = useState('')
const [accountCatalog, setAccountCatalog] = useState<{ accountId: string; models: ModelInfo[]; error: string } | null>(null)
Expand Down Expand Up @@ -146,12 +149,12 @@ export function AccessPage() {
}), [prompt, selectedModel])

const curl = useMemo(
() => `curl -sS ${shellQuote(`${base}/chat/completions`)} \\
() => `curl -sS ${shellQuote(chatEndpoint)} \\
-H "Authorization: Bearer $CLI2API_API_KEY" \\
-H ${shellQuote('Content-Type: application/json')}${selectedAccount ? ` \\
-H ${shellQuote(`X-Qoder-Account: ${selectedAccount}`)}` : ''} \\
-d ${shellQuote(JSON.stringify(payload))}`,
[base, payload, selectedAccount],
[chatEndpoint, payload, selectedAccount],
)

if (loading && !overview) return <AccessPageSkeleton />
Expand Down Expand Up @@ -201,6 +204,17 @@ export function AccessPage() {
</section>

<section data-gsap-reveal className="grid overflow-hidden rounded-3xl border border-border bg-surface sm:grid-cols-3">
<div className="border-b border-separator px-5 py-4 sm:col-span-3">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-semibold tracking-[-0.015em]">{t('connection')}</h3>
<p className="mt-0.5 text-xs leading-5 text-muted">{t('clientConfigHint')}</p>
</div>
<Chip size="sm" variant="soft" color={readyAccounts.length ? 'success' : 'warning'}>
{readyAccounts.length ? t('endpointReady') : t('degraded')}
</Chip>
</div>
</div>
<div className="min-w-0 border-b border-separator p-4 sm:col-span-2 sm:border-r sm:border-b-0 sm:p-5">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-medium text-muted">{t('baseUrl')}</span>
Expand All @@ -222,6 +236,8 @@ export function AccessPage() {
</div>
</section>

<EndpointList access={overview?.access} />

<Card data-gsap-reveal className="overflow-hidden p-0">
<div className="grid xl:grid-cols-[minmax(440px,.92fr)_minmax(0,1.08fr)]">
<div className="border-b border-separator xl:border-r xl:border-b-0">
Expand All @@ -232,7 +248,7 @@ export function AccessPage() {
</div>
<div>
<h3 className="font-semibold tracking-[-0.015em]">{t('requestBuilder')}</h3>
<p className="mt-0.5 text-xs text-muted">POST /chat/completions</p>
<p className="mt-0.5 text-xs text-muted">POST {chatPath}</p>
</div>
</div>
</div>
Expand Down
42 changes: 2 additions & 40 deletions frontend/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { Link } from 'react-router-dom'
import { Button, Card, Chip } from '@heroui/react'
import { Card, Chip } from '@heroui/react'
import {
ArrowSquareOut,
ArrowUpRight,
Copy,
Cube,
Pulse,
} from '@phosphor-icons/react'
Expand All @@ -22,7 +20,6 @@ import { useI18n } from '@/hooks/useI18n'
import { useOverview } from '@/hooks/useOverview'
import { formatCompact, formatLatency, formatPercent } from '@/lib/format'
import { accountProviderFamilyLabel, accountProviderLabel } from '@/lib/provider'
import { absUrl } from '@/lib/url'
import { ProviderMark } from '@/components/ProviderMark'

type AccountRow = NonNullable<Overview['accounts']>[number]
Expand Down Expand Up @@ -76,7 +73,6 @@ export function OverviewPage() {
const [hours, setHours] = useState<StatsWindow>(24)
const [stats, setStats] = useState<RequestStats | null>(null)
const [statsError, setStatsError] = useState('')
const [copiedEndpoint, setCopiedEndpoint] = useState('')
const [statsLoading, setStatsLoading] = useState(true)

const proxyOk = Boolean(overview?.proxy?.ok)
Expand All @@ -87,9 +83,7 @@ export function OverviewPage() {
const coolingAccounts = accounts.filter((account) => account.down_until || account.cooldown_until).length
const inFlight = accounts.reduce((total, account) => total + (account.in_flight ?? account.inFlight ?? 0), 0)
const modelCount = overview?.models?.length ?? 0
const lastError = accounts.map((account) => account.last_error || account.lastError).find(Boolean) || '—'
const traffic = stats ?? EMPTY_STATS
const base = absUrl(overview?.access?.openai_base_url || '/v1')

useEffect(() => {
let cancelled = false
Expand All @@ -112,13 +106,6 @@ export function OverviewPage() {
}
}, [hours])

const endpoints = useMemo(() => [
{ name: t('endpointOpenAI'), url: base, method: 'BASE' },
{ name: t('endpointChat'), url: absUrl(overview?.access?.chat_completions || `${base}/chat/completions`), method: 'POST' },
{ name: t('endpointModels'), url: absUrl(overview?.access?.models || `${base}/models`), method: 'GET' },
{ name: t('endpointHealth'), url: absUrl(overview?.access?.health || '/health'), method: 'GET' },
], [base, overview, t])

const metrics = [
{ label: t('metricRequests'), value: traffic.totals.requests as number | null, kind: 'compact' as const, detail: t('statsWindowHint', { window: t(hours === 1 ? 'statsWindow1h' : hours === 168 ? 'statsWindow7d' : 'statsWindow24h') }), ok: traffic.totals.requests > 0 },
{ label: t('metricSuccess'), value: traffic.totals.success_rate, kind: 'percent' as const, detail: `${traffic.totals.ok} ${t('logsFilterOk')} · ${traffic.totals.error} ${t('logsFilterError')}`, ok: traffic.totals.requests === 0 || traffic.totals.success_rate >= 0.9 },
Expand Down Expand Up @@ -395,31 +382,6 @@ export function OverviewPage() {
)}
</Card>

<Card data-gsap-reveal className="overflow-hidden p-0">
<div className="border-b border-separator px-5 py-4">
<h3 className="font-semibold tracking-[-0.015em]">{t('endpoints')}</h3>
<p className="mt-0.5 text-xs text-muted">{t('routesHint')}</p>
</div>
<div className="divide-y divide-separator">
{endpoints.map((item) => (
<div key={item.name} className="group grid gap-2 px-5 py-3.5 sm:grid-cols-[56px_minmax(0,1fr)_auto] sm:items-center">
<span className="mono text-[10px] font-semibold text-muted">{item.method}</span>
<div className="min-w-0">
<div className="text-sm font-medium">{item.name}</div>
<code className="mono mt-1 block truncate text-[11px] text-muted">{item.url}</code>
</div>
<div className="flex gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100">
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void navigator.clipboard.writeText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>{copiedEndpoint === item.name ? <span className="mono text-[9px] text-success">OK</span> : <Copy size={14} />}</Button>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('open')} onPress={() => window.open(item.url, '_blank', 'noopener,noreferrer')}><ArrowSquareOut size={14} /></Button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between border-t border-separator px-5 py-3 text-xs text-muted">
<span className="truncate">{lastError === '—' ? 'Authorization: Bearer' : lastError}</span>
<Link to="/logs" className="inline-flex items-center gap-1 hover:text-foreground">{t('navLogs')}<ArrowUpRight size={12} /></Link>
</div>
</Card>
</section>
</div>
)
Expand Down
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
"models": models,
"access": map[string]any{
"openai_base_url": "/v1", "chat_completions": endpoint.ChatCompletionsPath,
"messages": endpoint.MessagesPath, "responses": endpoint.ResponsesPath,
"models": endpoint.ModelsPath, "health": endpoint.HealthPath,
"hint": "Console APIs and /v1 require the API key stored in SQLite.",
},
Expand Down

Large diffs are not rendered by default.

25 changes: 0 additions & 25 deletions internal/webui/static/assets/index-B1CaSp6w.js

This file was deleted.

25 changes: 25 additions & 0 deletions internal/webui/static/assets/index-CBGxSNpJ.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-B1CaSp6w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C2r4Z2IX.css">
<script type="module" crossorigin src="/assets/index-CBGxSNpJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-wK-HEcN2.css">
</head>
<body>
<div id="root"></div>
Expand Down
Loading