import { useEffect, useMemo, useRef, useState } from 'react'; import { AssistantRuntimeProvider, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, ThreadPrimitive, useLocalRuntime, useMessage, useMessagePartText, useThread, type ChatModelAdapter, type ThreadMessage, type ThreadMessageLike, } from '@assistant-ui/react'; import { StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'; import { cjk } from '@streamdown/cjk'; import { code } from '@streamdown/code'; import { math } from '@streamdown/math'; import { mermaid } from '@streamdown/mermaid'; import type { GatewayApiKey } from '@easyai-ai-gateway/contracts'; import { Send } from 'lucide-react'; import { Button, Select } from '../components/ui'; import { GatewayApiError, streamChatCompletionText } from '../api'; import type { PlaygroundMode } from '../types'; import { chatUploadAccept as sharedChatUploadAccept, mediaUploadSummaryMessage as sharedMediaUploadSummaryMessage, openAIContentFromPromptAndUploads, PlaygroundReferencePicker, uploadPlaygroundFiles as sharedUploadPlaygroundFiles, type OpenAIChatContentPart, type PlaygroundUpload, } from './playground-upload'; import { ApiKeySelect, ModeSwitch, PlaygroundGreeting, apiKeyNoticeText, modeOptions, modelOptionLabel, placeholderByMode, resolveSelectedApiKeyId, type ModelOption, } from './playground-shared'; const CHAT_MESSAGES_STORAGE_KEY = 'easyai:playground:chat-messages:v1'; const CHAT_MESSAGES_STORAGE_LIMIT = 100; const streamdownPlugins = { cjk, code, math, mermaid }; type OpenAIChatRole = 'assistant' | 'user'; interface StoredOpenAIChatMessage { content: OpenAIChatContentPart[] | string; createdAt: string; id: string; role: OpenAIChatRole; } type StoredOpenAIChatMessagesById = Record; export function AssistantChatPlayground(props: { apiKeySecretsById: Record; apiKeys: GatewayApiKey[]; modelOptions: ModelOption[]; selectedApiKeyId: string; selectedModel: string; token: string; onApiKeyChange: (apiKeyId: string) => void; onCreateApiKey: () => void; onLogin: () => void; onModeChange: (mode: PlaygroundMode) => void; onModelChange: (value: string) => void; }) { const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId); const activeApiKeySecret = activeApiKeyId ? props.apiKeySecretsById[activeApiKeyId] ?? '' : ''; const canRun = Boolean(props.token && props.selectedModel && activeApiKeySecret); const apiKeyNotice = apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById); const initialStoredMessages = useMemo(() => readStoredOpenAIChatMessages(), []); const initialMessages = useMemo(() => initialStoredMessages.map(threadMessageLikeFromOpenAIMessage), [initialStoredMessages]); const [storedMessagesById, setStoredMessagesById] = useState(() => indexStoredOpenAIChatMessages(initialStoredMessages)); const [chatUploadMessage, setChatUploadMessage] = useState(''); const [chatUploads, setChatUploads] = useState([]); const [chatUploading, setChatUploading] = useState(false); const chatUploadsRef = useRef(chatUploads); const storedMessagesByIdRef = useRef(storedMessagesById); useEffect(() => { chatUploadsRef.current = chatUploads; }, [chatUploads]); useEffect(() => { storedMessagesByIdRef.current = storedMessagesById; }, [storedMessagesById]); async function uploadChatFiles(files: File[]) { if (!files.length) return; if (!props.token) { props.onLogin(); return; } if (!activeApiKeySecret) { setChatUploadMessage('请选择可用于测试的 API Key 后再上传。'); return; } setChatUploading(true); setChatUploadMessage(''); try { const { items, warnings } = await sharedUploadPlaygroundFiles(activeApiKeySecret, files, { allowFiles: true, source: 'ai-gateway-playground-chat', }); if (items.length) { setChatUploads((current) => [...current, ...items]); } setChatUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个附件。` : '')); } catch (err) { setChatUploadMessage(err instanceof Error ? err.message : '文件上传失败'); } finally { setChatUploading(false); } } const adapter = useMemo(() => ({ async *run({ abortSignal, messages }) { if (!props.token) { props.onLogin(); throw new GatewayApiError('请先登录后再测试模型。'); } if (!activeApiKeySecret) { throw new GatewayApiError('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。'); } if (!props.selectedModel) { throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。'); } const requestUploads = chatUploadsRef.current; const request = buildGatewayChatMessages(messages, requestUploads, storedMessagesByIdRef.current); if (request.lastUserMessage) { setStoredMessagesById((current) => ({ ...current, [request.lastUserMessage!.id]: request.lastUserMessage!, })); } if (requestUploads.length) { chatUploadsRef.current = []; setChatUploads([]); setChatUploadMessage(''); } let text = ''; for await (const delta of streamChatCompletionText( activeApiKeySecret, { messages: request.messages, model: props.selectedModel, }, abortSignal, )) { text += delta; yield { content: [{ type: 'text', text }], }; } yield { content: [{ type: 'text', text }], status: { type: 'complete', reason: 'stop' }, }; }, }), [activeApiKeySecret, props]); const runtime = useLocalRuntime(adapter, { initialMessages }); return (
setChatUploads((current) => current.filter((item) => item.id !== id))} onUploadFiles={(files) => void uploadChatFiles(files)} />
{apiKeyNotice && (
{apiKeyNotice}
)}
( ), }} />
setChatUploads((current) => current.filter((item) => item.id !== id))} onUploadFiles={(files) => void uploadChatFiles(files)} />
); } function AssistantChatPersistenceBridge(props: { storedMessagesById: StoredOpenAIChatMessagesById }) { const messages = useThread((state) => state.messages); const skipInitialEmptyWriteRef = useRef(true); useEffect(() => { if (skipInitialEmptyWriteRef.current) { skipInitialEmptyWriteRef.current = false; if (!messages.length && hasStoredChatMessages()) return; } writeStoredOpenAIChatMessages(messages, props.storedMessagesById); }, [messages, props.storedMessagesById]); return null; } function AssistantEmptyState(props: { activeApiKeySecret: string; apiKeyNotice: string; apiKeySecretsById: Record; apiKeys: GatewayApiKey[]; canRun: boolean; modelOptions: ModelOption[]; selectedApiKeyId: string; selectedModel: string; token: string; uploadAccept: string; uploadMessage: string; uploads: PlaygroundUpload[]; uploading: boolean; onApiKeyChange: (apiKeyId: string) => void; onCreateApiKey: () => void; onModeChange: (mode: PlaygroundMode) => void; onModelChange: (value: string) => void; onRemoveUpload: (id: string) => void; onUploadFiles: (files: File[]) => void; }) { const activeMode = modeOptions.find((item) => item.value === 'chat') ?? modeOptions[0]; const placeholder = props.canRun ? placeholderByMode.chat : assistantPlaceholder(props.token, props.selectedModel, props.activeApiKeySecret); return (
); } function AssistantChatComposer(props: { apiKeyNotice: string; apiKeySecretsById: Record; apiKeys: GatewayApiKey[]; canRun: boolean; docked?: boolean; modelOptions: ModelOption[]; placeholder: string; selectedApiKeyId: string; selectedModel: string; uploadAccept?: string; uploadMessage?: string; uploads?: PlaygroundUpload[]; uploading?: boolean; onApiKeyChange: (apiKeyId: string) => void; onCreateApiKey: () => void; onModeChange: (mode: PlaygroundMode) => void; onModelChange: (value: string) => void; onRemoveUpload?: (id: string) => void; onUploadFiles?: (files: File[]) => void; }) { const className = ['playgroundComposer', 'assistantChatComposer', props.docked ? 'assistantDockComposer' : 'assistantEmptyComposer'].join(' '); const uploadMessage = props.uploadMessage || sharedMediaUploadSummaryMessage(props.uploads ?? [], 'chat', 'text_to_video'); return (
{uploadMessage &&
{uploadMessage}
}
{props.apiKeyNotice && ( )}
); } function AssistantMessage(props: { storedMessagesById: StoredOpenAIChatMessagesById }) { const messageId = useMessage((state) => state.id); const messageContent = useMessage((state) => state.content); const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error'); const storedMessage = messageId ? props.storedMessagesById[messageId] : undefined; const imageParts = imagePartsFromOpenAIContent(storedMessage?.content); const hasText = threadMessageContentText(messageContent).trim().length > 0; return (
{hasText && (
)}
调用失败 {!hasError && ( 模型正在回复... )}
); } function ChatMessageImagePreviews(props: { parts: Array<{ name: string; url: string }> }) { if (!props.parts.length) return null; return (
{props.parts.map((item) => ( {item.name} ))}
); } function PlainMessageText() { const { text } = useMessagePartText(); return {text}; } function AssistantMarkdownText() { return ( ); } interface GatewayChatMessageForRequest extends Record { content: OpenAIChatContentPart[] | string; role: OpenAIChatRole; } function buildGatewayChatMessages( messages: readonly ThreadMessage[], uploads: PlaygroundUpload[], storedMessagesById: StoredOpenAIChatMessagesById, ) { const sourceMessages = messages.filter((message) => message.role === 'user' || message.role === 'assistant'); let sourceLastUserIndex = -1; sourceMessages.forEach((message, index) => { if (message.role === 'user') sourceLastUserIndex = index; }); const gatewayMessages: GatewayChatMessageForRequest[] = []; let lastUserMessage: StoredOpenAIChatMessage | undefined; sourceMessages.forEach((message, index) => { const isUploadTarget = uploads.length > 0 && index === sourceLastUserIndex && message.role === 'user'; const text = threadMessageText(message); const preserved = storedMessagesById[message.id]; const content = isUploadTarget ? openAIContentFromPromptAndUploads(text, uploads) : preserved?.content ?? text; if (!openAIContentHasPayload(content)) return; gatewayMessages.push({ content, role: message.role, }); if (isUploadTarget) { lastUserMessage = { content, createdAt: message.createdAt.toISOString(), id: message.id, role: 'user', }; } }); return { lastUserMessage, messages: gatewayMessages }; } function readStoredOpenAIChatMessages(): StoredOpenAIChatMessage[] { if (typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; const record = recordFromUnknown(parsed); const source = Array.isArray(parsed) ? parsed : record?.messages; if (!Array.isArray(source)) return []; const legacyImagesByMessageId = recordFromUnknown(record?.imageUploadsByMessageId); return source .map((item) => storedOpenAIChatMessageFromStorage(item, legacyImagesByMessageId)) .filter((item): item is StoredOpenAIChatMessage => Boolean(item)) .slice(-CHAT_MESSAGES_STORAGE_LIMIT); } catch { return []; } } function writeStoredOpenAIChatMessages(messages: readonly ThreadMessage[], preservedMessagesById: StoredOpenAIChatMessagesById) { if (typeof window === 'undefined') return; try { const storedMessages = messages .map((message) => storedOpenAIChatMessageFromThread(message, preservedMessagesById[message.id])) .filter((item): item is StoredOpenAIChatMessage => Boolean(item)) .slice(-CHAT_MESSAGES_STORAGE_LIMIT); if (!storedMessages.length) { window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY); return; } window.localStorage.setItem(CHAT_MESSAGES_STORAGE_KEY, JSON.stringify({ messages: storedMessages, version: 2, })); } catch { // Best effort only: local chat history should not block sending messages. } } export function clearStoredChatMessages() { if (typeof window === 'undefined') return; try { window.localStorage.removeItem(CHAT_MESSAGES_STORAGE_KEY); } catch { // Ignore storage errors. } } function hasStoredChatMessages() { if (typeof window === 'undefined') return false; try { return Boolean(window.localStorage.getItem(CHAT_MESSAGES_STORAGE_KEY)); } catch { return false; } } function storedOpenAIChatMessageFromThread(message: ThreadMessage, preserved?: StoredOpenAIChatMessage): StoredOpenAIChatMessage | undefined { if (message.role !== 'assistant' && message.role !== 'user') return undefined; const content = message.role === 'user' && preserved ? preserved.content : threadMessageText(message); if (!openAIContentHasPayload(content)) return undefined; return { content, createdAt: message.createdAt.toISOString(), id: message.id, role: message.role, }; } function storedOpenAIChatMessageFromStorage(value: unknown, legacyImagesByMessageId?: Record): StoredOpenAIChatMessage | undefined { const record = recordFromUnknown(value); if (!record) return undefined; const role = record.role === 'assistant' || record.role === 'user' ? record.role : undefined; const id = stringFromUnknown(record.id); if (!role || !id) return undefined; const createdAt = dateStringFromUnknown(record.createdAt) ?? new Date().toISOString(); let content = openAIContentFromUnknown(record.content); const legacyImages = Array.isArray(legacyImagesByMessageId?.[id]) ? legacyImagesByMessageId[id] as unknown[] : []; if (role === 'user' && legacyImages.length && (typeof content === 'string' || !content)) { content = openAIContentWithLegacyImages(typeof content === 'string' ? content : '', legacyImages); } if (!content || !openAIContentHasPayload(content)) return undefined; return { content, createdAt, id, role, }; } function threadMessageLikeFromOpenAIMessage(message: StoredOpenAIChatMessage): ThreadMessageLike { return { content: threadMessageLikeContentFromOpenAIContent(message.content), createdAt: new Date(message.createdAt), id: message.id, role: message.role, status: message.role === 'assistant' ? { type: 'complete', reason: 'stop' } : undefined, }; } function indexStoredOpenAIChatMessages(messages: StoredOpenAIChatMessage[]) { return messages.reduce((result, message) => { result[message.id] = message; return result; }, {}); } function threadMessageLikeContentFromOpenAIContent(content: OpenAIChatContentPart[] | string): ThreadMessageLike['content'] { const text = openAIContentText(content); return text || ' '; } function threadMessageText(message: ThreadMessage) { return threadMessageContentText(message.content).trim(); } function threadMessageContentText(content: ThreadMessage['content']) { return content .map((part) => part.type === 'text' ? part.text : '') .join('') .trim(); } function openAIContentFromUnknown(value: unknown): OpenAIChatContentPart[] | string | undefined { if (typeof value === 'string') return value; if (!Array.isArray(value)) return undefined; const parts = value .map(openAIContentPartFromUnknown) .filter((item): item is OpenAIChatContentPart => Boolean(item)); return parts.length ? parts : undefined; } function openAIContentPartFromUnknown(value: unknown): OpenAIChatContentPart | undefined { const record = recordFromUnknown(value); if (!record) return undefined; if (record.type === 'text' && typeof record.text === 'string') { return { type: 'text', text: record.text }; } if (record.type === 'image_url') { const imageUrl = recordFromUnknown(record.image_url); const url = stringFromUnknown(imageUrl?.url); return url ? { type: 'image_url', image_url: { url } } : undefined; } if (record.type === 'video_url') { const videoUrl = recordFromUnknown(record.video_url); const url = stringFromUnknown(videoUrl?.url); return url ? { type: 'video_url', video_url: { url } } : undefined; } if (record.type === 'audio_url') { const audioUrl = recordFromUnknown(record.audio_url); const url = stringFromUnknown(audioUrl?.url); return url ? { type: 'audio_url', audio_url: { url } } : undefined; } if (record.type === 'file_url') { const fileUrl = recordFromUnknown(record.file_url); const url = stringFromUnknown(fileUrl?.url); const filename = stringFromUnknown(fileUrl?.filename) || '文件'; return url ? { type: 'file_url', file_url: { filename, url } } : undefined; } return undefined; } function openAIContentWithLegacyImages(text: string, images: unknown[]): OpenAIChatContentPart[] { const content: OpenAIChatContentPart[] = []; if (text.trim()) { content.push({ type: 'text', text }); } images.forEach((item) => { const record = recordFromUnknown(item); const url = stringFromUnknown(record?.url); if (url) { content.push({ type: 'image_url', image_url: { url } }); } }); return content; } function openAIContentHasPayload(content: OpenAIChatContentPart[] | string) { if (typeof content === 'string') return content.trim().length > 0; return content.some((part) => { if (part.type === 'text') return part.text.trim().length > 0; return true; }); } function openAIContentText(content: OpenAIChatContentPart[] | string) { if (typeof content === 'string') return content; return content .map((part) => part.type === 'text' ? part.text : '') .join('') .trim(); } function imagePartsFromOpenAIContent(content: OpenAIChatContentPart[] | string | undefined) { if (!Array.isArray(content)) return []; return content.flatMap((part) => { if (part.type !== 'image_url') return []; return [{ name: '图片', url: part.image_url.url }]; }); } function assistantPlaceholder(token: string, selectedModel: string, apiKeySecret: string) { if (!token) return '请先登录后再测试模型'; if (!apiKeySecret) return '请选择可用于测试的 API Key'; if (!selectedModel) return '当前没有可用模型'; return '输入消息,Enter 发送,Shift + Enter 换行'; } function recordFromUnknown(value: unknown): Record | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; return value as Record; } function stringFromUnknown(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function dateStringFromUnknown(value: unknown) { if (typeof value !== 'string') return undefined; const timestamp = Date.parse(value); return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString(); }