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
7 changes: 7 additions & 0 deletions changelog/unreleased/access-try-tabs.md
Original file line number Diff line number Diff line change
@@ -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 共用。
85 changes: 84 additions & 1 deletion frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<string> {
const headers: Record<string, string> = { '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 = /<!doctype html|<html[\s>]/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
Expand Down
40 changes: 32 additions & 8 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const messages: Record<Lang, Dict> = {
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.',
Expand Down Expand Up @@ -196,7 +196,7 @@ export const messages: Record<Lang, Dict> = {
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.' },
Expand Down Expand Up @@ -400,8 +400,8 @@ export const messages: Record<Lang, Dict> = {
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',
Expand All @@ -419,6 +419,18 @@ export const messages: Record<Lang, Dict> = {
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.',
Expand Down Expand Up @@ -860,7 +872,7 @@ export const messages: Record<Lang, Dict> = {
navOverview: '概览',
navAuth: '账号',
navProviders: '模型',
navAccess: 'API 接入',
navAccess: '接入与调试',
navAccounts: '账号',
checkinDefaultsTitle: '自动签到',
checkinDefaultsHint: '按供应商设置默认时间。跟随默认的账号会立即采用新时间,账号自定义时间不受影响。',
Expand Down Expand Up @@ -1028,7 +1040,7 @@ export const messages: Record<Lang, Dict> = {
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: '查看近期代理请求和进程输出。' },
Expand Down Expand Up @@ -1232,8 +1244,8 @@ export const messages: Record<Lang, Dict> = {
providerFilterAll: '全部供应商',
accountStatusFilter: '账号状态',
connection: '连接信息',
apiPlayground: 'API 调试台',
apiPlaygroundHint: '组装一条真实请求,检查原始响应,再复制完全一致的调用命令。',
apiPlayground: '接入与调试',
apiPlaygroundHint: '账号与模型共用。对话页做单轮流式试玩;原生 API 页看原始 JSON 并复制 curl。',
readyAccounts: '{ready} / {total} 个账号就绪',
baseUrl: 'Base URL',
externalClients: '外部客户端',
Expand All @@ -1251,6 +1263,18 @@ export const messages: Record<Lang, Dict> = {
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。',
Expand Down
Loading
Loading