diff --git a/changelog/unreleased/access-chat-ui.md b/changelog/unreleased/access-chat-ui.md new file mode 100644 index 00000000..f94df39a --- /dev/null +++ b/changelog/unreleased/access-chat-ui.md @@ -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` 或「预览样例」。 diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 3c8a91fa..b2b916f9 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -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 + 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 + 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 { @@ -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, }) @@ -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): 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() @@ -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 + 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 diff --git a/frontend/src/components/EndpointList.tsx b/frontend/src/components/EndpointList.tsx index 8fa4ec5f..8635eb40 100644 --- a/frontend/src/components/EndpointList.tsx +++ b/frontend/src/components/EndpointList.tsx @@ -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' @@ -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: }, - { name: t('endpointChat'), url: absUrl(access?.chat_completions || `${base}/chat/completions`), method: 'POST', hint: t('endpointChatHint'), icon: }, - { name: t('endpointMessages'), url: absUrl(access?.messages || `${base}/messages`), method: 'POST', hint: t('endpointMessagesHint'), icon: }, - { name: t('endpointResponses'), url: absUrl(access?.responses || `${base}/responses`), method: 'POST', hint: t('endpointResponsesHint'), icon: }, - { name: t('endpointModels'), url: absUrl(access?.models || `${base}/models`), method: 'GET', hint: t('endpointModelsHint'), icon: }, - { name: t('endpointHealth'), url: absUrl(access?.health || '/health'), method: 'GET', hint: t('endpointHealthHint'), icon: }, + { name: t('endpointOpenAI'), url: base, method: 'BASE', hint: t('endpointBaseHint'), icon: }, + { name: t('endpointChat'), url: absUrl(access?.chat_completions || `${base}/chat/completions`), method: 'POST', hint: t('endpointChatHint'), icon: }, + { name: t('endpointMessages'), url: absUrl(access?.messages || `${base}/messages`), method: 'POST', hint: t('endpointMessagesHint'), icon: }, + { name: t('endpointResponses'), url: absUrl(access?.responses || `${base}/responses`), method: 'POST', hint: t('endpointResponsesHint'), icon: }, + { name: t('endpointModels'), url: absUrl(access?.models || `${base}/models`), method: 'GET', hint: t('endpointModelsHint'), icon: }, + { name: t('endpointHealth'), url: absUrl(access?.health || '/health'), method: 'GET', hint: t('endpointHealthHint'), icon: }, ], [access, base, t]) return ( - -
+
+

{t('endpoints')}

@@ -35,9 +35,9 @@ export function EndpointList({ access }: EndpointListProps) {

{t('routesHint')}

-
+
{endpoints.map((item) => ( -
+
{item.icon} @@ -48,8 +48,8 @@ export function EndpointList({ access }: EndpointListProps) {
{item.method}
-

{item.hint}

-
+

{item.hint}

+
{item.method === 'BASE' ? t('endpointBaseLabel') : t('endpointAuthLabel')}
))}
- +
) } diff --git a/frontend/src/components/access/ChatComposer.tsx b/frontend/src/components/access/ChatComposer.tsx new file mode 100644 index 00000000..f9313809 --- /dev/null +++ b/frontend/src/components/access/ChatComposer.tsx @@ -0,0 +1,291 @@ +import { useRef, type KeyboardEvent, type ReactNode } from 'react' +import { Button, Label, ListBox, Select, TextArea } from '@heroui/react' +import { FileText, PaperPlaneTilt, Plus, Stop, X } from '@phosphor-icons/react' +import { useI18n } from '@/hooks/useI18n' +import type { ChatAttachment } from './types' +import { IMAGE_ATTACHMENT_MIMES, TEXT_ATTACHMENT_EXTS, attachmentExt } from './types' + +function formatSize(size: number) { + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +async function readAttachment(file: File): Promise { + const ext = attachmentExt(file.name) + const mime = file.type || 'application/octet-stream' + const id = `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2, 8)}` + if (IMAGE_ATTACHMENT_MIMES.has(mime) || ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = () => reject(reader.error || new Error('read failed')) + reader.readAsDataURL(file) + }) + return { id, name: file.name, mime, kind: 'image', dataUrl, size: file.size } + } + if (TEXT_ATTACHMENT_EXTS.has(ext) || mime.startsWith('text/') || mime === 'application/json') { + const text = await file.text() + return { id, name: file.name, mime, kind: 'text', text, size: file.size } + } + return null +} + +export type ComposerOption = { + id: string + textValue: string + label: string + hint?: string + icon?: ReactNode +} + +function CompactSelect({ + ariaLabel, + value, + onChange, + placeholder, + isDisabled, + options, + className, + tone = 'default', +}: { + ariaLabel: string + value: string + onChange: (value: string) => void + placeholder?: string + isDisabled?: boolean + options: ComposerOption[] + className?: string + tone?: 'default' | 'muted' +}) { + const selected = options.find((option) => option.id === value) + const triggerTone = + tone === 'muted' + ? 'text-muted hover:text-foreground' + : 'text-foreground' + return ( + + ) +} + +export function ChatComposer({ + value, + onChange, + attachments, + onAttachmentsChange, + disabled, + streaming, + canSend, + onSend, + onStop, + accountValue, + onAccountChange, + accountOptions, + modelValue, + onModelChange, + modelOptions, + modelsLoading, +}: { + value: string + onChange: (value: string) => void + attachments: ChatAttachment[] + onAttachmentsChange: (next: ChatAttachment[]) => void + disabled?: boolean + streaming: boolean + canSend: boolean + onSend: () => void + onStop: () => void + accountValue: string + onAccountChange: (value: string) => void + accountOptions: ComposerOption[] + modelValue: string + onModelChange: (value: string) => void + modelOptions: ComposerOption[] + modelsLoading?: boolean +}) { + const { t } = useI18n() + const fileRef = useRef(null) + + async function onPickFiles(files: FileList | null) { + if (!files?.length) return + const accepted: ChatAttachment[] = [] + const rejected: string[] = [] + for (const file of Array.from(files)) { + try { + const item = await readAttachment(file) + if (item) accepted.push(item) + else rejected.push(file.name) + } catch { + rejected.push(file.name) + } + } + if (accepted.length) onAttachmentsChange([...attachments, ...accepted]) + if (rejected.length) { + window.alert(t('chatAttachUnsupported', { names: rejected.join(', ') })) + } + if (fileRef.current) fileRef.current.value = '' + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return + event.preventDefault() + if (!streaming && canSend) onSend() + } + + return ( +
+
+ {attachments.length ? ( +
+ {attachments.map((file) => ( + + {file.kind === 'image' && file.dataUrl ? ( + + ) : ( + + + + )} + + {file.name} + {formatSize(file.size)} + + + + ))} +
+ ) : null} + +