From 7e25942ccb4aa2a6a9db5907d91c25a52eaca3f9 Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:20:28 +0800 Subject: [PATCH] feat(console): Access page Chat + Native API tabs (P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename nav to Access & try / 接入与调试. Split /access into shared account/model/Base URL controls plus HeroUI Tabs: single-turn streaming Chat Completions and the existing non-stream playground + curl. Add streamChat SSE helper; keep EndpointList at page bottom. --- changelog/unreleased/access-try-tabs.md | 7 + frontend/src/api/overview.ts | 85 +++- frontend/src/i18n/messages.ts | 40 +- frontend/src/pages/AccessPage.tsx | 471 ++++++++++++------ ...D_dZFsMz.js => CartesianChart-BkK7I21K.js} | 2 +- ...-apU7YJ2t.js => LiveSparkline-DZtxZofB.js} | 2 +- ...t-3n7Tz3WL.js => TrafficChart-BWhHrHjK.js} | 2 +- ...{index-DOXtlbeo.css => index-D7XkWnN1.css} | 2 +- .../webui/static/assets/index-DLl0QtWB.js | 91 ++++ .../webui/static/assets/index-Dg5gEcNj.js | 90 ---- internal/webui/static/index.html | 4 +- 11 files changed, 526 insertions(+), 270 deletions(-) create mode 100644 changelog/unreleased/access-try-tabs.md rename internal/webui/static/assets/{CartesianChart-D_dZFsMz.js => CartesianChart-BkK7I21K.js} (99%) rename internal/webui/static/assets/{LiveSparkline-apU7YJ2t.js => LiveSparkline-DZtxZofB.js} (85%) rename internal/webui/static/assets/{TrafficChart-3n7Tz3WL.js => TrafficChart-BWhHrHjK.js} (99%) rename internal/webui/static/assets/{index-DOXtlbeo.css => index-D7XkWnN1.css} (99%) create mode 100644 internal/webui/static/assets/index-DLl0QtWB.js delete mode 100644 internal/webui/static/assets/index-Dg5gEcNj.js diff --git a/changelog/unreleased/access-try-tabs.md b/changelog/unreleased/access-try-tabs.md new file mode 100644 index 00000000..eab2ac30 --- /dev/null +++ b/changelog/unreleased/access-try-tabs.md @@ -0,0 +1,7 @@ +### English + +- Access page: rename nav to Access & try / 接入与调试, and split into Chat (single-turn streaming) and Native API (playground + curl) tabs with shared account, model, and Base URL. + +### 中文 + +- 接入页:侧栏改名为「接入与调试」,拆成「对话」(单轮流式)与「原生 API」(调试台 + curl)两个页签,账号、模型和 Base URL 共用。 diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index f6be2191..3c8a91fa 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -1,4 +1,5 @@ -import { api } from './client' +import { api, ApiError } from './client' +import { readStoredApiKey } from '@/hooks/useApiKey' import type { CheckinRecord, Overview } from './types' export function fetchOverviewSummary(keyOverride?: string) { @@ -167,6 +168,88 @@ export function testChat(model: string, content: string, accountId?: string) { }) } +export type StreamChatHandlers = { + onContent?: (chunk: string, full: string) => void + signal?: AbortSignal +} + +/** Single-turn streaming Chat Completions via console `/api/chat` (SSE). */ +export async function streamChat( + model: string, + content: string, + accountId: string | undefined, + handlers: StreamChatHandlers = {}, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' } + if (accountId) headers['X-Qoder-Account'] = accountId + const key = readStoredApiKey().trim() + if (key) headers.Authorization = `Bearer ${key}` + + const res = await fetch('/api/chat', { + method: 'POST', + headers, + body: JSON.stringify({ + model, + stream: true, + messages: [{ role: 'user', content }], + }), + signal: handlers.signal, + }) + + if (!res.ok) { + const text = await res.text() + let data: any + try { + data = text ? JSON.parse(text) : null + } catch { + data = text + } + const raw = typeof data === 'string' ? data : text + const looksHTML = /]/i.test(raw) + const msg = data?.error?.message || data?.message || (looksHTML ? `${res.status} ${res.statusText}` : (raw || res.statusText)) + throw new ApiError(msg, res.status, data?.error?.code) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('No response body') + + const decoder = new TextDecoder() + let buffer = '' + let full = '' + + const consumeLine = (line: string) => { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) return false + const payload = trimmed.slice(5).trim() + if (!payload) return false + if (payload === '[DONE]') return true + try { + const json = JSON.parse(payload) + const delta = json?.choices?.[0]?.delta?.content + if (typeof delta === 'string' && delta) { + full += delta + handlers.onContent?.(delta, full) + } + } catch { + // ignore incomplete JSON frames + } + return false + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + for (const line of lines) { + if (consumeLine(line)) return full + } + } + if (buffer.trim()) consumeLine(buffer) + return full +} + export type ProviderDescriptor = { id: string diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 557ed5b1..36a63fa3 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -28,7 +28,7 @@ export const messages: Record = { navOverview: 'Overview', navAuth: 'Accounts', navProviders: 'Models', - navAccess: 'Access', + navAccess: 'Access & try', navAccounts: 'Accounts', checkinDefaultsTitle: 'Automatic check-in', checkinDefaultsHint: 'Set a default time for each provider. Accounts following the default pick up changes automatically; custom times stay unchanged.', @@ -196,7 +196,7 @@ export const messages: Record = { home: { title: 'Overview', desc: 'Runtime health, request volume, and ready endpoints.' }, auth: { title: 'Auth', desc: 'Browser device-flow login, PAT login, and session rewarm.' }, providers: { title: 'Models', desc: 'Models from signed-in accounts, plus per-model defaults for context and reasoning.' }, - access: { title: 'Access', desc: 'Copy Base URL and run a quick Chat Completions check.' }, + access: { title: 'Access & try', desc: 'Try a single-turn chat or exercise the native API playground.' }, accounts: { title: 'Accounts', desc: 'One login per account. Browser sign-in first; PAT only when needed.' }, usage: { title: 'Usage', desc: 'Usage by model, account and provider.' }, logs: { title: 'Logs', desc: 'Recent proxy requests and process output.' }, @@ -400,8 +400,8 @@ export const messages: Record = { providerFilterAll: 'All providers', accountStatusFilter: 'Account status', connection: 'Connection', - apiPlayground: 'API playground', - apiPlaygroundHint: 'Compose a real request, inspect the raw response, then copy the matching command.', + apiPlayground: 'Access & try', + apiPlaygroundHint: 'Shared account and model controls. Use Chat for a single-turn stream, or Native API for raw JSON and curl.', readyAccounts: '{ready} of {total} accounts ready', baseUrl: 'Base URL', externalClients: 'External clients', @@ -419,6 +419,18 @@ export const messages: Record = { sendRequest: 'Send request', promptPlaceholder: 'Write a message for the model', promptHelper: 'This test uses a non-streaming Chat Completions request.', + accessTabChat: 'Chat', + accessTabApi: 'Native API', + accessTabsLabel: 'Access modes', + chatTryEmptyTitle: 'No reply yet', + chatTryEmptyHint: 'Write one message and send. The streamed answer appears here for this turn only.', + chatTryHelper: 'Single-turn streaming Chat Completions. Nothing is saved between sends.', + chatTrySend: 'Send', + chatTryStop: 'Stop', + chatTryStreaming: 'Streaming…', + chatTryUserLabel: 'You', + chatTryAssistantLabel: 'Assistant', + nativeApiHint: 'Non-streaming request, raw JSON response, and a matching curl command.', autoAccountHint: 'The scheduler chooses an available account.', fixedAccountHint: 'This request is pinned to the selected account.', modelRoutingHint: 'Uses the public model ID exposed by this proxy.', @@ -860,7 +872,7 @@ export const messages: Record = { navOverview: '概览', navAuth: '账号', navProviders: '模型', - navAccess: 'API 接入', + navAccess: '接入与调试', navAccounts: '账号', checkinDefaultsTitle: '自动签到', checkinDefaultsHint: '按供应商设置默认时间。跟随默认的账号会立即采用新时间,账号自定义时间不受影响。', @@ -1028,7 +1040,7 @@ export const messages: Record = { home: { title: '概览', desc: '查看运行状态、请求量与可用端点。' }, auth: { title: '授权', desc: '浏览器 device-flow 登录、PAT 登录,以及会话 rewarm。' }, providers: { title: '模型', desc: '已登录账号可用的模型,以及每个模型的上下文和推理默认值。' }, - access: { title: '接入', desc: '复制 Base URL,并快速验证 Chat Completions。' }, + access: { title: '接入与调试', desc: '单轮对话试玩,或用原生 API 调试台发真实请求。' }, accounts: { title: '账号', desc: '一个账号一个登录态。先走浏览器登录;浏览器不方便再用 PAT。' }, usage: { title: '用量', desc: '按模型、账号与供应商统计用量。' }, logs: { title: '日志', desc: '查看近期代理请求和进程输出。' }, @@ -1232,8 +1244,8 @@ export const messages: Record = { providerFilterAll: '全部供应商', accountStatusFilter: '账号状态', connection: '连接信息', - apiPlayground: 'API 调试台', - apiPlaygroundHint: '组装一条真实请求,检查原始响应,再复制完全一致的调用命令。', + apiPlayground: '接入与调试', + apiPlaygroundHint: '账号与模型共用。对话页做单轮流式试玩;原生 API 页看原始 JSON 并复制 curl。', readyAccounts: '{ready} / {total} 个账号就绪', baseUrl: 'Base URL', externalClients: '外部客户端', @@ -1251,6 +1263,18 @@ export const messages: Record = { sendRequest: '发送请求', promptPlaceholder: '输入要发送给模型的内容', promptHelper: '测试请求使用非流式 Chat Completions。', + accessTabChat: '对话', + accessTabApi: '原生 API', + accessTabsLabel: '接入方式', + chatTryEmptyTitle: '还没有回复', + chatTryEmptyHint: '写一条消息并发送。流式回复只显示本轮,不会保存。', + chatTryHelper: '单轮流式 Chat Completions。每次发送互不保留上下文。', + chatTrySend: '发送', + chatTryStop: '停止', + chatTryStreaming: '生成中…', + chatTryUserLabel: '你', + chatTryAssistantLabel: '助手', + nativeApiHint: '非流式请求、原始 JSON 响应,以及对应的 curl 命令。', autoAccountHint: '由调度器选择一个可用账号。', fixedAccountHint: '本次请求固定发送到所选账号。', modelRoutingHint: '使用当前代理暴露的公开模型 ID。', diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx index fa60b51c..20e6b6e1 100644 --- a/frontend/src/pages/AccessPage.tsx +++ b/frontend/src/pages/AccessPage.tsx @@ -1,18 +1,20 @@ import { CopyIcon } from '@/components/motion/CopyIcon' import { copyText } from '@/lib/clipboard' -import { useEffect, useMemo, useState, type ReactNode } from 'react' -import { Button, Card, Chip, Description, Label, ListBox, Select, Skeleton, TextArea } from '@heroui/react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { Button, Card, Chip, Description, Label, ListBox, Select, Skeleton, Tab, Tabs, TextArea } from '@heroui/react' import { ArrowsClockwise, BracketsCurly, + ChatTeardropText, CheckCircle, Clock, + Code, PaperPlaneTilt, - TerminalWindow, + Stop, } from '@phosphor-icons/react' import { useI18n } from '@/hooks/useI18n' import { useOverview } from '@/hooks/useOverview' -import { fetchAccounts, fetchModels, testChat } from '@/api/overview' +import { fetchAccounts, fetchModels, streamChat, testChat } from '@/api/overview' import type { ModelInfo, Overview } from '@/api/types' import { absUrl } from '@/lib/url' import { modelCreditsText, modelIsFree } from '@/lib/format' @@ -24,6 +26,7 @@ import { accountProviderLabel } from '@/lib/provider' import { EndpointList } from '@/components/EndpointList' type RequestState = 'idle' | 'loading' | 'success' | 'error' +type AccessTab = 'chat' | 'api' type PlaygroundOption = { id: string @@ -99,6 +102,7 @@ function PlaygroundSelect({ export function AccessPage() { const { t } = useI18n() const { overview, loading } = useOverview() + const [tab, setTab] = useState('chat') const [poolModels, setPoolModels] = useState([]) const [accounts, setAccounts] = useState>([]) useEffect(() => { @@ -117,6 +121,12 @@ export function AccessPage() { const [accountId, setAccountId] = useState('') const [accountCatalog, setAccountCatalog] = useState<{ accountId: string; models: ModelInfo[]; error: string } | null>(null) const [prompt, setPrompt] = useState('只回复OK') + const [chatPrompt, setChatPrompt] = useState('') + const [chatUser, setChatUser] = useState('') + const [chatReply, setChatReply] = useState('') + const [chatState, setChatState] = useState('idle') + const [chatError, setChatError] = useState('') + const chatAbortRef = useRef(null) const [output, setOutput] = useState('') const [requestState, setRequestState] = useState('idle') const [elapsedMs, setElapsedMs] = useState(null) @@ -148,6 +158,11 @@ export function AccessPage() { cancelled = true } }, [selectedAccount]) + + useEffect(() => () => { + chatAbortRef.current?.abort() + }, []) + const readyAccounts = accounts.filter((account) => account.enabled !== false && ( account.ready === true || account.hot === true || account.status === 'ready' || account.status === 'hot' )) @@ -192,6 +207,40 @@ export function AccessPage() { } } + function stopChat() { + chatAbortRef.current?.abort() + chatAbortRef.current = null + } + + async function onChatSend() { + const content = chatPrompt.trim() + if (!content || !selectedModel || modelsLoading || chatState === 'loading') return + stopChat() + const controller = new AbortController() + chatAbortRef.current = controller + setChatUser(content) + setChatReply('') + setChatError('') + setChatState('loading') + setChatPrompt('') + try { + await streamChat(selectedModel, content, selectedAccount || undefined, { + signal: controller.signal, + onContent: (_chunk, full) => setChatReply(full), + }) + setChatState('success') + } catch (error) { + if (controller.signal.aborted) { + setChatState('success') + return + } + setChatError(error instanceof Error ? error.message : String(error)) + setChatState('error') + } finally { + if (chatAbortRef.current === controller) chatAbortRef.current = null + } + } + const responseStatus = requestState === 'success' ? t('requestComplete') : requestState === 'error' @@ -200,10 +249,40 @@ export function AccessPage() { ? t('requesting') : t('requestIdle') + const accountOptions: PlaygroundOption[] = [ + { + id: 'auto', + textValue: t('autoAccount'), + label: t('autoAccount'), + icon: , + }, + ...accounts.map((account) => ({ + id: account.id, + textValue: `${account.name || account.id} ${accountProviderLabel(account.provider, account.region, t)}`, + label: account.name || account.id, + hint: accountProviderLabel(account.provider, account.region, t), + icon: , + })), + ] + + const modelOptions: PlaygroundOption[] = models.map((item) => { + const credits = modelCreditsText(item) + const free = modelIsFree(item) + const title = item.display_name || item.id + const badge = free ? t('modelFree') : credits + const provider = item.provider || item.owned_by || '' + return { + id: item.id, + textValue: `${title} ${item.id} ${provider} ${credits} ${free ? 'free' : ''}`, + label: badge ? `${title} ${badge}` : title, + hint: [item.id, provider, badge].filter(Boolean).join(' · '), + } + }) + return (
-
-
+
+

{t('apiPlayground')}

{t('apiPlaygroundHint')}

@@ -213,7 +292,7 @@ export function AccessPage() {
-
+
{t('baseUrl')}
@@ -248,189 +327,251 @@ export function AccessPage() {
- - - -
-
-
-
-
- + +
+
+
+ setAccountId(next === 'auto' ? '' : next)} + options={accountOptions} + /> +

{selectedAccount ? t('fixedAccountHint') : t('autoAccountHint')}

+
+
+ {modelsLoading ? ( +
+
{t('model')}
+
-
-

{t('requestBuilder')}

-

POST {chatPath}

+ ) : models.length ? ( + + ) : ( +
+
{t('model')}
+
+ {modelsError || (selectedAccount ? t('noAccountModels') : t('noModelsYet'))} +
-
+ )} +

{selectedAccount ? t('accountModelHint') : t('modelRoutingHint')}

+
-
-
-
- setAccountId(next === 'auto' ? '' : next)} - options={[ - { - id: 'auto', - textValue: t('autoAccount'), - label: t('autoAccount'), - icon: , - }, - ...accounts.map((account) => ({ - id: account.id, - textValue: `${account.name || account.id} ${accountProviderLabel(account.provider, account.region, t)}`, - label: account.name || account.id, - hint: accountProviderLabel(account.provider, account.region, t), - icon: , - })), - ]} - /> -

{selectedAccount ? t('fixedAccountHint') : t('autoAccountHint')}

-
+ {!accounts.length ? ( + + ) : null} +
+ -
- {modelsLoading ? ( -
-
{t('model')}
- -
- ) : models.length ? ( - { - const credits = modelCreditsText(item) - const free = modelIsFree(item) - const title = item.display_name || item.id - const badge = free ? t('modelFree') : credits - const provider = item.provider || item.owned_by || '' - return { - id: item.id, - textValue: `${title} ${item.id} ${provider} ${credits} ${free ? 'free' : ''}`, - label: badge ? `${title} ${badge}` : title, - hint: [item.id, provider, badge].filter(Boolean).join(' · '), - } - })} - /> - ) : ( -
-
{t('model')}
-
- {modelsError || (selectedAccount ? t('noAccountModels') : t('noModelsYet'))} + setTab(String(key) as AccessTab)}> + + + + + + {t('accessTabChat')} + + + + + {t('accessTabApi')} + + + + + + +
+
+ {chatState === 'idle' && !chatUser ? ( + } + title={t('chatTryEmptyTitle')} + hint={t('chatTryEmptyHint')} + /> + ) : ( +
+ {chatUser ? ( +
+
{t('chatTryUserLabel')}
+
{chatUser}
+
+ ) : null} +
+
+ {t('chatTryAssistantLabel')} + {chatState === 'loading' ? {t('chatTryStreaming')} : null}
+ {chatState === 'error' ? ( + + ) : chatReply ? ( +
{chatReply}
+ ) : chatState === 'loading' ? ( +
+ + + + +
+ ) : null}
- )} -

{selectedAccount ? t('accountModelHint') : t('modelRoutingHint')}

-
+
+ )}
-
-
-
{t('prompt')}
- {prompt.length} -
+