From 8bf5b02744bf2c6321540ca261181d37776504f5 Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:24:55 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(console):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E9=A1=B5=E4=B8=BB=E5=8C=BA=E6=94=BE=E5=A4=A7=E4=B8=BA=E5=A4=9A?= =?UTF-8?q?=E8=BD=AE=E5=AF=B9=E8=AF=9D=EF=BC=8C=E6=94=AF=E6=8C=81=E6=80=9D?= =?UTF-8?q?=E8=80=83=E4=B8=8E=E5=B7=A5=E5=85=B7=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 布局改为大对话框为主、原始 API 折叠在下;streamChat 解析 reasoning_content 与 tool_calls。参考 affectUI 信息结构,用现有 HeroUI/灰度 patterns 自建,未引入 affectUI 包。 --- changelog/unreleased/access-chat-ui.md | 7 + frontend/src/api/overview.ts | 89 +++- .../src/components/access/ChatComposer.tsx | 152 ++++++ .../src/components/access/ChatMessage.tsx | 71 +++ frontend/src/components/access/ChatThread.tsx | 45 ++ .../src/components/access/RawApiPanel.tsx | 178 ++++++++ .../src/components/access/ThinkingBlock.tsx | 70 +++ .../src/components/access/ToolCallCard.tsx | 70 +++ .../src/components/access/buildMessages.ts | 45 ++ frontend/src/components/access/types.ts | 46 ++ frontend/src/i18n/messages.ts | 42 +- frontend/src/pages/AccessPage.tsx | 431 ++++++------------ ...hart-Br25AwKZ.js => AreaChart-CBvGYSog.js} | 2 +- ...DPn_BqXW.js => CartesianChart-BHG7Mx7i.js} | 2 +- ...hart-EBzNpO2R.js => CostChart-_Efyqs9c.js} | 2 +- ...-CktxERJb.js => LiveSparkline-DJ4GNYmC.js} | 2 +- ...t-CVQmXS3v.js => TrafficChart-Ds6AMpAy.js} | 2 +- .../{XAxis-Crvgd10p.js => XAxis-BSnjOw6o.js} | 2 +- ...{index-Ht8tQrqh.css => index-CHH3hxud.css} | 2 +- .../webui/static/assets/index-CkHn9_SJ.js | 93 ++++ .../webui/static/assets/index-Dn51f00C.js | 91 ---- internal/webui/static/index.html | 4 +- 22 files changed, 1055 insertions(+), 393 deletions(-) create mode 100644 changelog/unreleased/access-chat-ui.md create mode 100644 frontend/src/components/access/ChatComposer.tsx create mode 100644 frontend/src/components/access/ChatMessage.tsx create mode 100644 frontend/src/components/access/ChatThread.tsx create mode 100644 frontend/src/components/access/RawApiPanel.tsx create mode 100644 frontend/src/components/access/ThinkingBlock.tsx create mode 100644 frontend/src/components/access/ToolCallCard.tsx create mode 100644 frontend/src/components/access/buildMessages.ts create mode 100644 frontend/src/components/access/types.ts rename internal/webui/static/assets/{AreaChart-Br25AwKZ.js => AreaChart-CBvGYSog.js} (60%) rename internal/webui/static/assets/{CartesianChart-DPn_BqXW.js => CartesianChart-BHG7Mx7i.js} (99%) rename internal/webui/static/assets/{CostChart-EBzNpO2R.js => CostChart-_Efyqs9c.js} (93%) rename internal/webui/static/assets/{LiveSparkline-CktxERJb.js => LiveSparkline-DJ4GNYmC.js} (73%) rename internal/webui/static/assets/{TrafficChart-CVQmXS3v.js => TrafficChart-Ds6AMpAy.js} (98%) rename internal/webui/static/assets/{XAxis-Crvgd10p.js => XAxis-BSnjOw6o.js} (99%) rename internal/webui/static/assets/{index-Ht8tQrqh.css => index-CHH3hxud.css} (90%) create mode 100644 internal/webui/static/assets/index-CkHn9_SJ.js delete mode 100644 internal/webui/static/assets/index-Dn51f00C.js diff --git a/changelog/unreleased/access-chat-ui.md b/changelog/unreleased/access-chat-ui.md new file mode 100644 index 00000000..27af3514 --- /dev/null +++ b/changelog/unreleased/access-chat-ui.md @@ -0,0 +1,7 @@ +### English + +- Access page: larger multi-turn chat with thinking / tool-call cards; Raw API moves below the conversation. + +### 中文 + +- 接入页:主区放大为多轮对话,支持思考与工具卡片;原始 API 移到对话下方。 diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 3c8a91fa..912af7d3 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -168,15 +168,75 @@ 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 StreamChatHandlers = { onContent?: (chunk: string, full: string) => void + onReasoning?: (chunk: string, full: string) => void + onToolCalls?: (toolCalls: StreamToolCall[]) => 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 +245,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 +278,8 @@ export async function streamChat( const decoder = new TextDecoder() let buffer = '' let full = '' + let reasoning = '' + let toolCalls: StreamToolCall[] = [] const consumeLine = (line: string) => { const trimmed = line.trim() @@ -225,10 +289,21 @@ 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 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/access/ChatComposer.tsx b/frontend/src/components/access/ChatComposer.tsx new file mode 100644 index 00000000..abd03686 --- /dev/null +++ b/frontend/src/components/access/ChatComposer.tsx @@ -0,0 +1,152 @@ +import { useRef, type KeyboardEvent } from 'react' +import { Button, TextArea } from '@heroui/react' +import { Paperclip, PaperPlaneTilt, 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 function ChatComposer({ + value, + onChange, + attachments, + onAttachmentsChange, + disabled, + streaming, + canSend, + onSend, + onStop, +}: { + value: string + onChange: (value: string) => void + attachments: ChatAttachment[] + onAttachmentsChange: (next: ChatAttachment[]) => void + disabled?: boolean + streaming: boolean + canSend: boolean + onSend: () => void + onStop: () => void +}) { + 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.name} + {formatSize(file.size)} + + + ))} +
+ ) : null} + +