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
11 changes: 11 additions & 0 deletions changelog/unreleased/access-chat-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- Access chat polish: collapsed thinking pill with spinner/shimmer, grouped tool calls, compact composer selects (menus open upward), message foot with copy + credits.
- Access chat: catalog credits stay on the model picker; per-turn foot notes show tokens + credits only when the stream reports `usage.credits` / `consumed_credits`.
- Access page: multi-turn chat stage with AffectUI-like composer (attach + compact account/model + round send in one pad); thinking / tool-call cards use surface hierarchy; Raw API and endpoints drop heavy borders. Dev preview via `?demo=1` or Preview sample.

### 中文

- 接入页对话抛光:思考默认一行 pill(spinner/shimmer)、工具分组折叠、Composer 控件更紧(菜单上弹)、气泡脚注复制 + credits。
- 接入页对话:选型继续用目录 credits;本轮脚注仅在流式 `usage.credits` / `consumed_credits` 有值时显示 token + 消耗。
- 接入页:多轮对话主舞台,Composer 一体卡(附件 + 紧凑账号/模型 + 圆形发送);思考/工具卡靠表面层级、少描边;原始 API 与端点卡去硬边框。开发预览:`?demo=1` 或「预览样例」。
119 changes: 112 additions & 7 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,85 @@ export function testChat(model: string, content: string, accountId?: string) {
})
}

export type StreamChatContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }

export type StreamChatMessage = {
role: 'user' | 'assistant' | 'system' | 'tool'
content: string | StreamChatContentPart[]
name?: string
tool_call_id?: string
tool_calls?: StreamToolCall[]
reasoning_content?: string
}

export type StreamToolCall = {
index: number
id?: string
type?: string
function?: {
name?: string
arguments?: string
}
}

export type StreamChatUsage = {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
/** Subscription credit spend when the upstream reports it. */
credits?: number
source?: string
}

export type StreamChatHandlers = {
onContent?: (chunk: string, full: string) => void
onReasoning?: (chunk: string, full: string) => void
onToolCalls?: (toolCalls: StreamToolCall[]) => void
onUsage?: (usage: StreamChatUsage) => void
signal?: AbortSignal
}

/** Single-turn streaming Chat Completions via console `/api/chat` (SSE). */
function mergeToolCallDeltas(existing: StreamToolCall[], patches: unknown): StreamToolCall[] {
if (!Array.isArray(patches)) return existing
const next = existing.map((item) => ({
...item,
function: item.function ? { ...item.function } : undefined,
}))
for (const patch of patches) {
if (!patch || typeof patch !== 'object') continue
const raw = patch as Record<string, unknown>
const index = typeof raw.index === 'number' ? raw.index : next.length
while (next.length <= index) {
next.push({ index: next.length, function: { name: '', arguments: '' } })
}
const target = next[index]
target.index = index
if (typeof raw.id === 'string' && raw.id) target.id = raw.id
if (typeof raw.type === 'string' && raw.type) target.type = raw.type
const fn = raw.function
if (fn && typeof fn === 'object') {
const fnRaw = fn as Record<string, unknown>
if (!target.function) target.function = { name: '', arguments: '' }
if (typeof fnRaw.name === 'string' && fnRaw.name) target.function.name = fnRaw.name
if (typeof fnRaw.arguments === 'string' && fnRaw.arguments) {
target.function.arguments = (target.function.arguments || '') + fnRaw.arguments
}
}
}
return next
}

function normalizeStreamMessages(content: string | StreamChatMessage[]): StreamChatMessage[] {
if (typeof content === 'string') return [{ role: 'user', content }]
return content
}

/** Streaming Chat Completions via console `/api/chat` (SSE). `content` may be a string or a messages array. */
export async function streamChat(
model: string,
content: string,
content: string | StreamChatMessage[],
accountId: string | undefined,
handlers: StreamChatHandlers = {},
): Promise<string> {
Expand All @@ -185,13 +255,15 @@ export async function streamChat(
const key = readStoredApiKey().trim()
if (key) headers.Authorization = `Bearer ${key}`

const messages = normalizeStreamMessages(content)

const res = await fetch('/api/chat', {
method: 'POST',
headers,
body: JSON.stringify({
model,
stream: true,
messages: [{ role: 'user', content }],
messages,
}),
signal: handlers.signal,
})
Expand All @@ -216,6 +288,16 @@ export async function streamChat(
const decoder = new TextDecoder()
let buffer = ''
let full = ''
let reasoning = ''
let toolCalls: StreamToolCall[] = []

const readUsageCredits = (raw: Record<string, unknown>): number | undefined => {
for (const key of ['credits', 'credit', 'consumed_credits', 'ConsumedCredits'] as const) {
const value = raw[key]
if (typeof value === 'number' && Number.isFinite(value)) return value
}
return undefined
}

const consumeLine = (line: string) => {
const trimmed = line.trim()
Expand All @@ -225,10 +307,33 @@ export async function streamChat(
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)
const usageRaw = json?.usage
if (usageRaw && typeof usageRaw === 'object') {
const raw = usageRaw as Record<string, unknown>
const usage: StreamChatUsage = {
prompt_tokens: typeof raw.prompt_tokens === 'number' ? raw.prompt_tokens : undefined,
completion_tokens: typeof raw.completion_tokens === 'number' ? raw.completion_tokens : undefined,
total_tokens: typeof raw.total_tokens === 'number' ? raw.total_tokens : undefined,
credits: readUsageCredits(raw),
source: typeof raw.source === 'string' ? raw.source : undefined,
}
handlers.onUsage?.(usage)
}
const delta = json?.choices?.[0]?.delta
if (!delta || typeof delta !== 'object') return false
const contentDelta = delta.content
if (typeof contentDelta === 'string' && contentDelta) {
full += contentDelta
handlers.onContent?.(contentDelta, full)
}
const reasoningDelta = delta.reasoning_content
if (typeof reasoningDelta === 'string' && reasoningDelta) {
reasoning += reasoningDelta
handlers.onReasoning?.(reasoningDelta, reasoning)
}
if (delta.tool_calls) {
toolCalls = mergeToolCallDeltas(toolCalls, delta.tool_calls)
handlers.onToolCalls?.(toolCalls)
}
} catch {
// ignore incomplete JSON frames
Expand Down
28 changes: 14 additions & 14 deletions frontend/src/components/EndpointList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CopyIcon } from '@/components/motion/CopyIcon'
import { copyText } from '@/lib/clipboard'
import { useMemo, useState } from 'react'
import { Button, Card, Chip } from '@heroui/react'
import { Button, Chip } from '@heroui/react'
import { ArrowSquareOut, BracketsCurly, Heartbeat, ListBullets, PaperPlaneTilt } from '@phosphor-icons/react'
import type { Overview } from '@/api/types'
import { useI18n } from '@/hooks/useI18n'
Expand All @@ -16,17 +16,17 @@ export function EndpointList({ access }: EndpointListProps) {
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} /> },
{ name: t('endpointOpenAI'), url: base, method: 'BASE', hint: t('endpointBaseHint'), icon: <BracketsCurly size={15} /> },
{ name: t('endpointChat'), url: absUrl(access?.chat_completions || `${base}/chat/completions`), method: 'POST', hint: t('endpointChatHint'), icon: <PaperPlaneTilt size={15} /> },
{ name: t('endpointMessages'), url: absUrl(access?.messages || `${base}/messages`), method: 'POST', hint: t('endpointMessagesHint'), icon: <PaperPlaneTilt size={15} /> },
{ name: t('endpointResponses'), url: absUrl(access?.responses || `${base}/responses`), method: 'POST', hint: t('endpointResponsesHint'), icon: <PaperPlaneTilt size={15} /> },
{ name: t('endpointModels'), url: absUrl(access?.models || `${base}/models`), method: 'GET', hint: t('endpointModelsHint'), icon: <ListBullets size={15} /> },
{ name: t('endpointHealth'), url: absUrl(access?.health || '/health'), method: 'GET', hint: t('endpointHealthHint'), icon: <Heartbeat size={15} /> },
], [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-4 sm:px-6">
<section className="overflow-hidden rounded-3xl bg-surface shadow-surface">
<div className="flex items-start justify-between gap-4 px-5 py-4 sm:px-6">
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold">{t('endpoints')}</h3>
Expand All @@ -35,9 +35,9 @@ export function EndpointList({ access }: EndpointListProps) {
<p className="mt-1 text-caption leading-5 text-muted">{t('routesHint')}</p>
</div>
</div>
<div className="grid gap-2.5 p-3 sm:grid-cols-2 sm:p-4 lg:grid-cols-3">
<div className="grid gap-2 px-3 pb-3 sm:grid-cols-2 sm:px-4 sm:pb-4 lg:grid-cols-3">
{endpoints.map((item) => (
<div key={item.name} className="group min-w-0 rounded-lg bg-surface-secondary/45 p-3 transition-colors hover:bg-surface-secondary/65">
<div key={item.name} className="group min-w-0 rounded-xl bg-surface-secondary/40 p-3 transition-colors hover:bg-surface-secondary/70">
<div className="flex items-start justify-between gap-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-surface text-muted">{item.icon}</span>
Expand All @@ -48,8 +48,8 @@ export function EndpointList({ access }: EndpointListProps) {
</div>
<Chip size="sm" variant="soft">{item.method}</Chip>
</div>
<p className="mt-2 min-h-0 text-caption leading-5 text-muted">{item.hint}</p>
<div className="mt-2 flex items-center justify-between gap-2 pt-2">
<p className="mt-2 text-caption leading-5 text-muted">{item.hint}</p>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-caption 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 copyText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
Expand All @@ -63,6 +63,6 @@ export function EndpointList({ access }: EndpointListProps) {
</div>
))}
</div>
</Card>
</section>
)
}
Loading
Loading