Refactor gateway task handling and recording flow
This commit is contained in:
parent
0cd4e6fed1
commit
b8a716169f
File diff suppressed because it is too large
Load Diff
727
apps/web/src/pages/playground-chat.tsx
Normal file
727
apps/web/src/pages/playground-chat.tsx
Normal file
@ -0,0 +1,727 @@
|
||||
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<string, StoredOpenAIChatMessage>;
|
||||
|
||||
export function AssistantChatPlayground(props: {
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
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<StoredOpenAIChatMessagesById>(() => indexStoredOpenAIChatMessages(initialStoredMessages));
|
||||
const [chatUploadMessage, setChatUploadMessage] = useState('');
|
||||
const [chatUploads, setChatUploads] = useState<PlaygroundUpload[]>([]);
|
||||
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<ChatModelAdapter>(() => ({
|
||||
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 (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantChatPersistenceBridge storedMessagesById={storedMessagesById} />
|
||||
<ThreadPrimitive.Root className="assistantThreadRoot">
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className="assistantEmptyStage">
|
||||
<AssistantEmptyState
|
||||
apiKeyNotice={apiKeyNotice}
|
||||
apiKeySecretsById={props.apiKeySecretsById}
|
||||
apiKeys={props.apiKeys}
|
||||
canRun={canRun}
|
||||
modelOptions={props.modelOptions}
|
||||
selectedApiKeyId={activeApiKeyId}
|
||||
selectedModel={props.selectedModel}
|
||||
token={props.token}
|
||||
activeApiKeySecret={activeApiKeySecret}
|
||||
uploadAccept={sharedChatUploadAccept}
|
||||
uploadMessage={chatUploadMessage}
|
||||
uploads={chatUploads}
|
||||
uploading={chatUploading}
|
||||
onApiKeyChange={props.onApiKeyChange}
|
||||
onCreateApiKey={props.onCreateApiKey}
|
||||
onModeChange={props.onModeChange}
|
||||
onModelChange={props.onModelChange}
|
||||
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||
/>
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.If empty={false}>
|
||||
<div className="assistantShell" data-has-notice={Boolean(apiKeyNotice)}>
|
||||
{apiKeyNotice && (
|
||||
<div className="assistantApiKeyNotice">
|
||||
<span>{apiKeyNotice}</span>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
|
||||
去创建 API Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport className="assistantThreadViewport">
|
||||
<div className="assistantMessageList">
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
Message: () => (
|
||||
<AssistantMessage storedMessagesById={storedMessagesById} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ThreadPrimitive.ViewportFooter className="assistantComposerDock">
|
||||
<AssistantChatComposer
|
||||
apiKeyNotice={apiKeyNotice}
|
||||
apiKeySecretsById={props.apiKeySecretsById}
|
||||
apiKeys={props.apiKeys}
|
||||
canRun={canRun}
|
||||
docked
|
||||
modelOptions={props.modelOptions}
|
||||
placeholder={assistantPlaceholder(props.token, props.selectedModel, activeApiKeySecret)}
|
||||
selectedApiKeyId={activeApiKeyId}
|
||||
selectedModel={props.selectedModel}
|
||||
uploadAccept={sharedChatUploadAccept}
|
||||
uploadMessage={chatUploadMessage}
|
||||
uploads={chatUploads}
|
||||
uploading={chatUploading}
|
||||
onApiKeyChange={props.onApiKeyChange}
|
||||
onCreateApiKey={props.onCreateApiKey}
|
||||
onModeChange={props.onModeChange}
|
||||
onModelChange={props.onModelChange}
|
||||
onRemoveUpload={(id) => setChatUploads((current) => current.filter((item) => item.id !== id))}
|
||||
onUploadFiles={(files) => void uploadChatFiles(files)}
|
||||
/>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</div>
|
||||
</ThreadPrimitive.If>
|
||||
</ThreadPrimitive.Root>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
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 (
|
||||
<div className="assistantEmpty">
|
||||
<ModeSwitch activeMode="chat" onModeChange={props.onModeChange} />
|
||||
<PlaygroundGreeting activeMode={activeMode} />
|
||||
<AssistantChatComposer
|
||||
apiKeyNotice={props.apiKeyNotice}
|
||||
apiKeySecretsById={props.apiKeySecretsById}
|
||||
apiKeys={props.apiKeys}
|
||||
canRun={props.canRun}
|
||||
modelOptions={props.modelOptions}
|
||||
placeholder={placeholder}
|
||||
selectedApiKeyId={props.selectedApiKeyId}
|
||||
selectedModel={props.selectedModel}
|
||||
uploadAccept={props.uploadAccept}
|
||||
uploadMessage={props.uploadMessage}
|
||||
uploads={props.uploads}
|
||||
uploading={props.uploading}
|
||||
onApiKeyChange={props.onApiKeyChange}
|
||||
onCreateApiKey={props.onCreateApiKey}
|
||||
onModeChange={props.onModeChange}
|
||||
onModelChange={props.onModelChange}
|
||||
onRemoveUpload={props.onRemoveUpload}
|
||||
onUploadFiles={props.onUploadFiles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantChatComposer(props: {
|
||||
apiKeyNotice: string;
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
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 (
|
||||
<ComposerPrimitive.Root className={className}>
|
||||
<div className="composerBody composerBodyWithReferences">
|
||||
<PlaygroundReferencePicker
|
||||
accept={props.uploadAccept ?? sharedChatUploadAccept}
|
||||
disabled={!props.canRun || !props.onUploadFiles}
|
||||
mode="chat"
|
||||
uploadLabel="上传附件"
|
||||
uploads={props.uploads ?? []}
|
||||
uploading={props.uploading}
|
||||
onFiles={props.onUploadFiles}
|
||||
onRemove={props.onRemoveUpload}
|
||||
/>
|
||||
<div className="composerInputStack">
|
||||
<ComposerPrimitive.Input
|
||||
className="assistantEmptyInput"
|
||||
disabled={!props.canRun}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
{uploadMessage && <div className="composerUploadMessage">{uploadMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="composerFooter">
|
||||
<Select value="chat" onChange={(event) => props.onModeChange(event.target.value as PlaygroundMode)}>
|
||||
{modeOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
|
||||
</Select>
|
||||
<Select
|
||||
className="playgroundModelSelect"
|
||||
value={props.selectedModel}
|
||||
disabled={!props.modelOptions.length}
|
||||
onChange={(event) => props.onModelChange(event.target.value)}
|
||||
>
|
||||
{props.modelOptions.length ? props.modelOptions.map((item) => (
|
||||
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
||||
)) : <option value="">没有可用模型</option>}
|
||||
</Select>
|
||||
<ApiKeySelect
|
||||
apiKeySecretsById={props.apiKeySecretsById}
|
||||
apiKeys={props.apiKeys}
|
||||
selectedApiKeyId={props.selectedApiKeyId}
|
||||
onApiKeyChange={props.onApiKeyChange}
|
||||
/>
|
||||
{props.apiKeyNotice && (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={props.onCreateApiKey}>
|
||||
创建 API Key
|
||||
</Button>
|
||||
)}
|
||||
<ComposerPrimitive.Send className="composerSendButton" disabled={!props.canRun} aria-label="发送消息">
|
||||
<Send size={18} />
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<MessagePrimitive.Root className="assistantMessage">
|
||||
<MessagePrimitive.If user>
|
||||
<div className="assistantUserMessage">
|
||||
<ChatMessageImagePreviews parts={imageParts} />
|
||||
{hasText && (
|
||||
<div className="assistantBubble user">
|
||||
<MessagePrimitive.Parts components={{ Text: PlainMessageText }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.If>
|
||||
<MessagePrimitive.If assistant>
|
||||
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
|
||||
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
|
||||
<MessagePrimitive.Error>
|
||||
<strong>调用失败</strong>
|
||||
<ErrorPrimitive.Message className="assistantErrorMessage" />
|
||||
</MessagePrimitive.Error>
|
||||
{!hasError && (
|
||||
<MessagePrimitive.If hasContent={false}>
|
||||
<span className="assistantTyping">模型正在回复...</span>
|
||||
</MessagePrimitive.If>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.If>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageImagePreviews(props: { parts: Array<{ name: string; url: string }> }) {
|
||||
if (!props.parts.length) return null;
|
||||
return (
|
||||
<div className="assistantUserImageGrid">
|
||||
{props.parts.map((item) => (
|
||||
<a className="assistantUserImagePreview" href={item.url} key={item.url} rel="noreferrer" target="_blank" title={item.name}>
|
||||
<img src={item.url} alt={item.name} loading="lazy" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlainMessageText() {
|
||||
const { text } = useMessagePartText();
|
||||
return <span className="assistantPlainText">{text}</span>;
|
||||
}
|
||||
|
||||
function AssistantMarkdownText() {
|
||||
return (
|
||||
<StreamdownTextPrimitive
|
||||
containerClassName="assistantMarkdown"
|
||||
plugins={streamdownPlugins}
|
||||
shikiTheme={['github-light', 'github-dark']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GatewayChatMessageForRequest extends Record<string, unknown> {
|
||||
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<string, unknown>): 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<StoredOpenAIChatMessagesById>((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<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
120
apps/web/src/pages/playground-shared.tsx
Normal file
120
apps/web/src/pages/playground-shared.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { GatewayApiKey, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { Bot, Image as ImageIcon, Video } from 'lucide-react';
|
||||
import { Select } from '../components/ui';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import type { PlaygroundVideoCreateMode } from './playground-upload';
|
||||
|
||||
export type VideoCreateMode = PlaygroundVideoCreateMode;
|
||||
|
||||
export interface ModelOption {
|
||||
count: number;
|
||||
label: string;
|
||||
models: PlatformModel[];
|
||||
provider: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const modeOptions: Array<{ description: string; icon: ReactNode; label: string; value: PlaygroundMode }> = [
|
||||
{ value: 'chat', label: '大模型对话', description: '对话、推理、结构化输出', icon: <Bot size={16} /> },
|
||||
{ value: 'image', label: '图像生成', description: '文生图、图像编辑参数预览', icon: <ImageIcon size={16} /> },
|
||||
{ value: 'video', label: '视频生成', description: '图生视频、文生视频任务测试', icon: <Video size={16} /> },
|
||||
];
|
||||
|
||||
export const videoModeOptions: Array<{ label: string; value: VideoCreateMode }> = [
|
||||
{ value: 'text_to_video', label: '文生视频' },
|
||||
{ value: 'first_last_frame', label: '首尾帧' },
|
||||
{ value: 'omni_reference', label: '全能参考' },
|
||||
];
|
||||
|
||||
export const placeholderByMode: Record<PlaygroundMode, string> = {
|
||||
chat: '输入问题、角色设定或测试提示词,支持 OpenAI 兼容格式验证...',
|
||||
image: '描述你想生成的画面,例如:未来城市中的玻璃温室,晨光,电影级构图...',
|
||||
video: '描述视频镜头、主体运动和风格,例如:低角度跟拍,一辆复古跑车穿过雨夜街道...',
|
||||
};
|
||||
|
||||
export const quickPrompts: Record<PlaygroundMode, string[]> = {
|
||||
chat: ['写一个产品发布摘要', '生成接口调用示例', '分析失败重试策略'],
|
||||
image: ['产品海报', '角色设定图', '电商主图'],
|
||||
video: ['5 秒运镜', '首帧转视频', '宣传短片'],
|
||||
};
|
||||
|
||||
export function resolveSelectedApiKeyId(apiKeys: GatewayApiKey[], secretsById: Record<string, string>, selectedApiKeyId: string) {
|
||||
if (selectedApiKeyId && secretsById[selectedApiKeyId]) return selectedApiKeyId;
|
||||
const firstUsable = apiKeys.find((item) => Boolean(secretsById[item.id]));
|
||||
return firstUsable?.id ?? '';
|
||||
}
|
||||
|
||||
export function apiKeyNoticeText(apiKeys: GatewayApiKey[], secretsById: Record<string, string>) {
|
||||
if (!apiKeys.length) return '当前账号还没有可用 API Key,请先创建一个 Key。';
|
||||
if (!apiKeys.some((item) => Boolean(secretsById[item.id]))) {
|
||||
return '当前没有可用于在线测试的完整 API Key,请重新加载或创建一个 Key。';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function modelOptionLabel(option: ModelOption) {
|
||||
const count = option.count > 1 ? ` · ${option.count} 个客户端` : '';
|
||||
const provider = option.provider ? ` · ${option.provider}` : '';
|
||||
return `${option.label}${provider}${count}`;
|
||||
}
|
||||
|
||||
export function ApiKeySelect(props: {
|
||||
apiKeySecretsById: Record<string, string>;
|
||||
apiKeys: GatewayApiKey[];
|
||||
selectedApiKeyId: string;
|
||||
onApiKeyChange: (apiKeyId: string) => void;
|
||||
}) {
|
||||
const activeApiKeyId = resolveSelectedApiKeyId(props.apiKeys, props.apiKeySecretsById, props.selectedApiKeyId);
|
||||
return (
|
||||
<Select
|
||||
className="playgroundApiKeySelect"
|
||||
value={activeApiKeyId}
|
||||
disabled={!props.apiKeys.length}
|
||||
onChange={(event) => props.onApiKeyChange(event.target.value)}
|
||||
>
|
||||
{!activeApiKeyId && <option value="">{props.apiKeys.length ? '选择 API Key' : '暂无 API Key'}</option>}
|
||||
{props.apiKeys.map((item) => {
|
||||
const usable = Boolean(props.apiKeySecretsById[item.id]);
|
||||
return (
|
||||
<option value={item.id} key={item.id} disabled={!usable}>
|
||||
{item.name} · {item.keyPrefix}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModeSwitch(props: {
|
||||
activeMode: PlaygroundMode;
|
||||
onModeChange: (mode: PlaygroundMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="playgroundModeSwitch">
|
||||
{modeOptions.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.value}
|
||||
data-active={props.activeMode === item.value}
|
||||
onClick={() => props.onModeChange(item.value)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaygroundGreeting(props: {
|
||||
activeMode: { description: string; label: string };
|
||||
}) {
|
||||
return (
|
||||
<div className="playgroundGreeting">
|
||||
<span>你好,想测试什么?</span>
|
||||
<strong>{props.activeMode.label}</strong>
|
||||
<small>{props.activeMode.description}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
721
apps/web/src/pages/playground-upload.tsx
Normal file
721
apps/web/src/pages/playground-upload.tsx
Normal file
@ -0,0 +1,721 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
LoaderCircle,
|
||||
Music2,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Repeat2,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { uploadFileToStorage } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
|
||||
export type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||
export type PlaygroundUploadRole = 'first_frame' | 'last_frame';
|
||||
export type PlaygroundVideoCreateMode = 'text_to_video' | 'first_last_frame' | 'omni_reference';
|
||||
|
||||
export interface PlaygroundUpload {
|
||||
contentType: string;
|
||||
id: string;
|
||||
kind: PlaygroundUploadKind;
|
||||
name: string;
|
||||
raw: Record<string, unknown>;
|
||||
role?: PlaygroundUploadRole;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type OpenAIChatContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
| { type: 'video_url'; video_url: { url: string } }
|
||||
| { type: 'audio_url'; audio_url: { url: string } }
|
||||
| { type: 'file_url'; file_url: { filename: string; url: string } };
|
||||
|
||||
export const mediaUploadAccept = 'image/*,video/*,audio/*';
|
||||
export const imageOnlyUploadAccept = 'image/*';
|
||||
export const chatUploadAccept = [
|
||||
mediaUploadAccept,
|
||||
'.csv',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.json',
|
||||
'.jsonl',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.pdf',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.txt',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'application/json',
|
||||
'application/msword',
|
||||
'application/pdf',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/*',
|
||||
].join(',');
|
||||
|
||||
export function ComposerUploadButton(props: {
|
||||
accept: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
uploading?: boolean;
|
||||
onFiles?: (files: File[]) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const disabled = props.disabled || props.uploading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="composerUpload"
|
||||
aria-label="上传附件"
|
||||
data-active={props.active === true}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Paperclip size={18} />}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
accept={props.accept}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
props.onFiles?.(files);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlaygroundReferencePicker(props: {
|
||||
accept: string;
|
||||
disabled?: boolean;
|
||||
mode: PlaygroundMode;
|
||||
uploadLabel?: string;
|
||||
uploads: PlaygroundUpload[];
|
||||
uploading?: boolean;
|
||||
videoMode?: PlaygroundVideoCreateMode;
|
||||
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onSwapFrames?: () => void;
|
||||
}) {
|
||||
if (props.mode === 'video' && props.videoMode === 'first_last_frame') {
|
||||
return (
|
||||
<FirstLastFramePicker
|
||||
accept={props.accept}
|
||||
disabled={props.disabled}
|
||||
uploads={props.uploads}
|
||||
uploading={props.uploading}
|
||||
onFiles={props.onFiles}
|
||||
onRemove={props.onRemove}
|
||||
onSwapFrames={props.onSwapFrames}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StackedReferencePicker
|
||||
accept={props.accept}
|
||||
disabled={props.disabled}
|
||||
uploadLabel={props.uploadLabel ?? (props.mode === 'chat' ? '上传附件' : '参考内容')}
|
||||
uploads={props.uploads}
|
||||
uploading={props.uploading}
|
||||
onFiles={props.onFiles}
|
||||
onRemove={props.onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedReferencePicker(props: {
|
||||
accept: string;
|
||||
disabled?: boolean;
|
||||
uploadLabel: string;
|
||||
uploads: PlaygroundUpload[];
|
||||
uploading?: boolean;
|
||||
onFiles?: (files: File[]) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [hoveredId, setHoveredId] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hoveredUpload = props.uploads.find((item) => item.id === hoveredId);
|
||||
const disabled = props.disabled || props.uploading || !props.onFiles;
|
||||
const uploadCardIndex = props.uploads.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mediaReferenceStack"
|
||||
data-expanded={expanded}
|
||||
onMouseLeave={() => {
|
||||
setExpanded(false);
|
||||
setHoveredId('');
|
||||
}}
|
||||
>
|
||||
{hoveredUpload && <div className="mediaReferenceTooltip">{hoveredUpload.name}</div>}
|
||||
<div
|
||||
className="mediaReferenceStackCards"
|
||||
data-empty={!props.uploads.length}
|
||||
style={{ '--reference-count': Math.max(1, props.uploads.length + 1) } as CSSProperties}
|
||||
>
|
||||
{props.uploads.map((item, index) => (
|
||||
<div
|
||||
className="mediaReferenceCard"
|
||||
data-hovered={hoveredId === item.id}
|
||||
data-kind={item.kind}
|
||||
key={item.id}
|
||||
style={referenceCardStyle(index)}
|
||||
title={item.name}
|
||||
onMouseEnter={() => {
|
||||
setExpanded(true);
|
||||
setHoveredId(item.id);
|
||||
}}
|
||||
>
|
||||
<ReferencePreview item={item} />
|
||||
{item.kind !== 'image' && <span className="mediaReferenceDuration">{uploadKindLabel(item.kind)}</span>}
|
||||
{props.onRemove && hoveredId === item.id && (
|
||||
<button type="button" className="mediaReferenceRemove" aria-label={`删除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mediaReferenceCard mediaReferenceUploadCard"
|
||||
aria-label={props.uploadLabel}
|
||||
data-has-uploads={props.uploads.length > 0}
|
||||
data-uploading={Boolean(props.uploading)}
|
||||
disabled={disabled}
|
||||
style={referenceCardStyle(uploadCardIndex)}
|
||||
title={props.uploadLabel}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onMouseEnter={() => setHoveredId('')}
|
||||
>
|
||||
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Plus size={20} />}
|
||||
<span>{props.uploadLabel}</span>
|
||||
</button>
|
||||
{props.uploads.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="mediaReferenceAdd"
|
||||
aria-label={props.uploadLabel}
|
||||
data-uploading={Boolean(props.uploading)}
|
||||
disabled={disabled}
|
||||
title={props.uploadLabel}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onMouseEnter={() => {
|
||||
setExpanded(false);
|
||||
setHoveredId('');
|
||||
}}
|
||||
>
|
||||
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={15} /> : <Plus size={17} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
accept={props.accept}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
props.onFiles?.(files);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const referenceTiltValues = [-7, 6, -3, 8, -5, 4, -8, 5];
|
||||
const referenceXValues = [0, -5, 6, -3, 4, -6, 3, -4];
|
||||
const referenceYValues = [0, 3, -1, 4, 1, 5, 2, -2];
|
||||
|
||||
function referenceCardStyle(index: number) {
|
||||
const valueIndex = index % referenceTiltValues.length;
|
||||
return {
|
||||
'--reference-index': index,
|
||||
'--reference-tilt': `${referenceTiltValues[valueIndex]}deg`,
|
||||
'--reference-x': `${referenceXValues[valueIndex]}px`,
|
||||
'--reference-y': `${referenceYValues[valueIndex]}px`,
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function FirstLastFramePicker(props: {
|
||||
accept: string;
|
||||
disabled?: boolean;
|
||||
uploads: PlaygroundUpload[];
|
||||
uploading?: boolean;
|
||||
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onSwapFrames?: () => void;
|
||||
}) {
|
||||
const first = frameUploadByRole(props.uploads, 'first_frame');
|
||||
const last = frameUploadByRole(props.uploads, 'last_frame');
|
||||
const canSwap = Boolean(first && last);
|
||||
|
||||
return (
|
||||
<div className="firstLastFramePicker">
|
||||
<FrameSlot
|
||||
accept={props.accept}
|
||||
disabled={props.disabled}
|
||||
item={first}
|
||||
label="首帧"
|
||||
role="first_frame"
|
||||
uploading={props.uploading}
|
||||
onFiles={props.onFiles}
|
||||
onRemove={props.onRemove}
|
||||
/>
|
||||
<button type="button" className="frameSwapButton" aria-label="交换首尾帧" disabled={!canSwap} onClick={props.onSwapFrames}>
|
||||
<Repeat2 size={19} />
|
||||
</button>
|
||||
<FrameSlot
|
||||
accept={props.accept}
|
||||
disabled={props.disabled}
|
||||
item={last}
|
||||
label="尾帧"
|
||||
role="last_frame"
|
||||
uploading={props.uploading}
|
||||
onFiles={props.onFiles}
|
||||
onRemove={props.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FrameSlot(props: {
|
||||
accept: string;
|
||||
disabled?: boolean;
|
||||
item?: PlaygroundUpload;
|
||||
label: string;
|
||||
role: PlaygroundUploadRole;
|
||||
uploading?: boolean;
|
||||
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const disabled = props.disabled || props.uploading || !props.onFiles;
|
||||
|
||||
return (
|
||||
<div className="frameSlot">
|
||||
<button
|
||||
type="button"
|
||||
className="frameSlotButton"
|
||||
data-filled={Boolean(props.item)}
|
||||
disabled={disabled}
|
||||
title={props.item?.name ?? props.label}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{props.item ? <ReferencePreview item={props.item} /> : <Plus size={20} />}
|
||||
<span>{props.label}</span>
|
||||
</button>
|
||||
{props.item && props.onRemove && (
|
||||
<button type="button" className="frameSlotRemove" aria-label={`删除 ${props.label}`} onClick={() => props.onRemove?.(props.item!.id)}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept={props.accept}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
props.onFiles?.(files, props.role);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferencePreview(props: { item: PlaygroundUpload }) {
|
||||
if (props.item.kind === 'image') {
|
||||
return <img src={props.item.url} alt="" draggable={false} />;
|
||||
}
|
||||
if (props.item.kind === 'video') {
|
||||
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
||||
}
|
||||
if (props.item.kind === 'audio') {
|
||||
return (
|
||||
<span className="mediaReferencePlaceholder">
|
||||
<Music2 size={18} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mediaReferencePlaceholder">
|
||||
<FileText size={18} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadAttachmentList(props: {
|
||||
message?: string;
|
||||
uploads: PlaygroundUpload[];
|
||||
onRemove?: (id: string) => void;
|
||||
}) {
|
||||
if (!props.uploads.length && !props.message) return null;
|
||||
return (
|
||||
<div className="composerUploadArea">
|
||||
{props.uploads.length > 0 && (
|
||||
<div className="composerUploadList">
|
||||
{props.uploads.map((item) => (
|
||||
<span className="composerUploadChip" key={item.id} title={`${item.name} · ${item.url}`}>
|
||||
{uploadKindIcon(item.kind)}
|
||||
<span>{item.name}</span>
|
||||
<small>{formatFileSize(item.size)}</small>
|
||||
{props.onRemove && (
|
||||
<button type="button" aria-label={`移除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{props.message && <div className="composerUploadMessage">{props.message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uploadKindIcon(kind: PlaygroundUploadKind) {
|
||||
if (kind === 'image') return <ImageIcon size={14} />;
|
||||
if (kind === 'video') return <Video size={14} />;
|
||||
if (kind === 'audio') return <Music2 size={14} />;
|
||||
return <FileText size={14} />;
|
||||
}
|
||||
|
||||
export async function uploadPlaygroundFiles(
|
||||
token: string,
|
||||
files: File[],
|
||||
options: { allowFiles: boolean; allowedKinds?: PlaygroundUploadKind[]; source: string },
|
||||
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
||||
const allowedKinds = options.allowedKinds ?? (options.allowFiles ? ['audio', 'file', 'image', 'video'] : ['audio', 'image', 'video']);
|
||||
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
||||
const warnings: string[] = [];
|
||||
files.forEach((file) => {
|
||||
const kind = acceptedUploadKind(file, options.allowFiles);
|
||||
if (!kind || !allowedKinds.includes(kind)) {
|
||||
warnings.push(options.allowFiles
|
||||
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
||||
: `已跳过 ${file.name},当前场景仅支持${allowedUploadKindLabel(allowedKinds)}。`);
|
||||
return;
|
||||
}
|
||||
accepted.push({ file, kind });
|
||||
});
|
||||
if (!accepted.length) return { items: [], warnings };
|
||||
const items = await Promise.all(accepted.map(async ({ file, kind }) => {
|
||||
const response = await uploadFileToStorage(token, file, options.source);
|
||||
const url = uploadResponseUrl(response);
|
||||
if (!url) {
|
||||
throw new Error(`${file.name} 上传成功,但网关没有返回可用文件 URL。`);
|
||||
}
|
||||
return {
|
||||
contentType: file.type || '',
|
||||
id: newLocalId(),
|
||||
kind,
|
||||
name: file.name || '未命名文件',
|
||||
raw: response,
|
||||
size: file.size,
|
||||
url,
|
||||
};
|
||||
}));
|
||||
return { items, warnings };
|
||||
}
|
||||
|
||||
function acceptedUploadKind(file: File, allowFiles: boolean): PlaygroundUploadKind | undefined {
|
||||
const mime = file.type.toLowerCase();
|
||||
const extension = fileExtension(file.name);
|
||||
if (mime.startsWith('image/') || imageExtensions.has(extension)) return 'image';
|
||||
if (mime.startsWith('video/') || videoExtensions.has(extension)) return 'video';
|
||||
if (mime.startsWith('audio/') || audioExtensions.has(extension)) return 'audio';
|
||||
if (allowFiles && (documentExtensions.has(extension) || documentMimes.has(mime))) return 'file';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const imageExtensions = new Set(['avif', 'bmp', 'gif', 'heic', 'heif', 'jpeg', 'jpg', 'png', 'svg', 'tif', 'tiff', 'webp']);
|
||||
const videoExtensions = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'webm']);
|
||||
const audioExtensions = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'weba']);
|
||||
const documentExtensions = new Set(['csv', 'doc', 'docx', 'json', 'jsonl', 'md', 'markdown', 'pdf', 'ppt', 'pptx', 'txt', 'xls', 'xlsx', 'yaml', 'yml']);
|
||||
const documentMimes = new Set([
|
||||
'application/json',
|
||||
'application/msword',
|
||||
'application/pdf',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'text/plain',
|
||||
'text/yaml',
|
||||
]);
|
||||
|
||||
function fileExtension(name: string) {
|
||||
const index = name.lastIndexOf('.');
|
||||
return index >= 0 ? name.slice(index + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function uploadResponseUrl(response: Record<string, unknown>) {
|
||||
const data = recordFromUnknown(response.data);
|
||||
const file = recordFromUnknown(response.file);
|
||||
const result = recordFromUnknown(response.result);
|
||||
return firstString(
|
||||
response.url,
|
||||
response.fileUrl,
|
||||
response.file_url,
|
||||
response.objectUrl,
|
||||
response.object_url,
|
||||
response.downloadUrl,
|
||||
response.download_url,
|
||||
data?.url,
|
||||
data?.fileUrl,
|
||||
data?.file_url,
|
||||
file?.url,
|
||||
file?.fileUrl,
|
||||
file?.file_url,
|
||||
result?.url,
|
||||
result?.fileUrl,
|
||||
result?.file_url,
|
||||
);
|
||||
}
|
||||
|
||||
export function openAIContentFromPromptAndUploads(prompt: string, uploads: PlaygroundUpload[]): OpenAIChatContentPart[] {
|
||||
const content: OpenAIChatContentPart[] = [];
|
||||
const text = prompt.trim();
|
||||
if (text) {
|
||||
content.push({ type: 'text', text });
|
||||
}
|
||||
uploads.forEach((item) => {
|
||||
const part = openAIContentPartFromUpload(item);
|
||||
if (part) content.push(part);
|
||||
});
|
||||
return content.length ? content : [{ type: 'text', text: '' }];
|
||||
}
|
||||
|
||||
function openAIContentPartFromUpload(item: PlaygroundUpload): OpenAIChatContentPart | undefined {
|
||||
if (!item.url) return undefined;
|
||||
if (item.kind === 'image') return { type: 'image_url', image_url: { url: item.url } };
|
||||
if (item.kind === 'video') return { type: 'video_url', video_url: { url: item.url } };
|
||||
if (item.kind === 'audio') return { type: 'audio_url', audio_url: { url: item.url } };
|
||||
return { type: 'file_url', file_url: { filename: item.name, url: item.url } };
|
||||
}
|
||||
|
||||
export function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>, videoMode: PlaygroundVideoCreateMode) {
|
||||
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
||||
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
|
||||
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
|
||||
const payload: Record<string, string | string[]> = {};
|
||||
if (mode === 'image') {
|
||||
if (images.length) {
|
||||
payload.image = singleOrMany(images);
|
||||
payload.images = images;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
if (videoMode === 'first_last_frame') {
|
||||
const first = frameUploadByRole(uploads, 'first_frame');
|
||||
const last = frameUploadByRole(uploads, 'last_frame');
|
||||
if (first) {
|
||||
payload.first_frame = first.url;
|
||||
}
|
||||
if (last) {
|
||||
payload.last_frame = last.url;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
if (images.length) {
|
||||
payload.reference_image = singleOrMany(images);
|
||||
}
|
||||
if (videos.length) {
|
||||
payload.reference_video = singleOrMany(videos);
|
||||
}
|
||||
if (audios.length) {
|
||||
payload.reference_audio = singleOrMany(audios);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function singleOrMany(values: string[]) {
|
||||
return values.length === 1 ? values[0] : values;
|
||||
}
|
||||
|
||||
export function uploadKindLabel(kind: PlaygroundUploadKind) {
|
||||
if (kind === 'image') return '图片';
|
||||
if (kind === 'video') return '视频';
|
||||
if (kind === 'audio') return '音频';
|
||||
return '文件';
|
||||
}
|
||||
|
||||
export function allowedUploadKindLabel(kinds: PlaygroundUploadKind[]) {
|
||||
const labels = kinds.map(uploadKindLabel);
|
||||
return labels.length ? labels.join('、') : '当前文件类型';
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number) {
|
||||
if (!Number.isFinite(size) || size <= 0) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function mediaUploadAcceptForMode(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
||||
if (mode === 'image') return imageOnlyUploadAccept;
|
||||
if (mode === 'video' && videoMode === 'first_last_frame') return imageOnlyUploadAccept;
|
||||
return mediaUploadAccept;
|
||||
}
|
||||
|
||||
export function allowedMediaUploadKinds(mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode): PlaygroundUploadKind[] {
|
||||
if (mode === 'image') return ['image'];
|
||||
if (mode === 'video' && videoMode === 'first_last_frame') return ['image'];
|
||||
if (mode === 'video') return ['audio', 'image', 'video'];
|
||||
return ['audio', 'file', 'image', 'video'];
|
||||
}
|
||||
|
||||
export function mediaUploadSummaryMessage(uploads: PlaygroundUpload[], mode: PlaygroundMode, videoMode: PlaygroundVideoCreateMode) {
|
||||
if (!uploads.length) return '';
|
||||
const images = uploads.filter((item) => item.kind === 'image').length;
|
||||
const videos = uploads.filter((item) => item.kind === 'video').length;
|
||||
const audios = uploads.filter((item) => item.kind === 'audio').length;
|
||||
const files = uploads.filter((item) => item.kind === 'file').length;
|
||||
if (mode === 'image') {
|
||||
return `已上传 ${images} 张参考图。`;
|
||||
}
|
||||
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||
const first = frameUploadByRole(uploads, 'first_frame');
|
||||
const last = frameUploadByRole(uploads, 'last_frame');
|
||||
if (first && last) return '已上传首帧、尾帧参考图。';
|
||||
if (first) return '已上传首帧参考图。';
|
||||
if (last) return '已上传尾帧参考图。';
|
||||
return `已上传 ${images} 张首尾帧参考图。`;
|
||||
}
|
||||
const parts = [
|
||||
images ? `${images} 张图片` : '',
|
||||
videos ? `${videos} 个视频` : '',
|
||||
audios ? `${audios} 段音频` : '',
|
||||
files ? `${files} 个文件` : '',
|
||||
].filter(Boolean);
|
||||
return parts.length ? `已上传 ${parts.join('、')}。` : '';
|
||||
}
|
||||
|
||||
export function mergeMediaUploadsForMode(
|
||||
current: PlaygroundUpload[],
|
||||
items: PlaygroundUpload[],
|
||||
mode: PlaygroundMode,
|
||||
videoMode: PlaygroundVideoCreateMode,
|
||||
targetRole?: PlaygroundUploadRole,
|
||||
) {
|
||||
if (mode === 'image') {
|
||||
return [...current.filter((item) => item.kind === 'image'), ...items.filter((item) => item.kind === 'image')];
|
||||
}
|
||||
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||
return mergeFirstLastFrameUploads(current, items, targetRole);
|
||||
}
|
||||
if (mode === 'video') {
|
||||
return [...current, ...items.filter((item) => item.kind === 'image' || item.kind === 'video' || item.kind === 'audio')];
|
||||
}
|
||||
return [...current, ...items];
|
||||
}
|
||||
|
||||
export function normalizeFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||
const images = uploads.filter((item) => item.kind === 'image');
|
||||
if (!images.length) return uploads.length ? [] : uploads;
|
||||
const first = frameUploadByRole(images, 'first_frame') ?? images[0];
|
||||
const last = frameUploadByRole(images, 'last_frame') ?? images.find((item) => item.id !== first?.id);
|
||||
const next: PlaygroundUpload[] = [];
|
||||
if (first) next.push({ ...first, role: 'first_frame' });
|
||||
if (last) next.push({ ...last, role: 'last_frame' });
|
||||
return uploadListsEqual(uploads, next) ? uploads : next;
|
||||
}
|
||||
|
||||
function mergeFirstLastFrameUploads(current: PlaygroundUpload[], items: PlaygroundUpload[], targetRole?: PlaygroundUploadRole) {
|
||||
const incoming = items.filter((item) => item.kind === 'image');
|
||||
let next = normalizeFirstLastFrameUploads(current);
|
||||
if (!incoming.length) return next;
|
||||
const assignUpload = (item: PlaygroundUpload, role: PlaygroundUploadRole) => {
|
||||
next = next.filter((upload) => upload.role !== role);
|
||||
next.push({ ...item, role });
|
||||
};
|
||||
if (targetRole) {
|
||||
assignUpload(incoming[0]!, targetRole);
|
||||
const oppositeRole: PlaygroundUploadRole = targetRole === 'first_frame' ? 'last_frame' : 'first_frame';
|
||||
incoming.slice(1).forEach((item) => {
|
||||
if (!frameUploadByRole(next, oppositeRole)) assignUpload(item, oppositeRole);
|
||||
});
|
||||
return sortFrameUploads(next);
|
||||
}
|
||||
incoming.forEach((item) => {
|
||||
if (!frameUploadByRole(next, 'first_frame')) {
|
||||
assignUpload(item, 'first_frame');
|
||||
} else if (!frameUploadByRole(next, 'last_frame')) {
|
||||
assignUpload(item, 'last_frame');
|
||||
}
|
||||
});
|
||||
return sortFrameUploads(next);
|
||||
}
|
||||
|
||||
export function swapFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||
return sortFrameUploads(uploads.map((item) => {
|
||||
if (item.role === 'first_frame') return { ...item, role: 'last_frame' as const };
|
||||
if (item.role === 'last_frame') return { ...item, role: 'first_frame' as const };
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function sortFrameUploads(uploads: PlaygroundUpload[]) {
|
||||
const first = frameUploadByRole(uploads, 'first_frame');
|
||||
const last = frameUploadByRole(uploads, 'last_frame');
|
||||
return [first, last].filter((item): item is PlaygroundUpload => Boolean(item));
|
||||
}
|
||||
|
||||
export function frameUploadByRole(uploads: PlaygroundUpload[], role: PlaygroundUploadRole) {
|
||||
return uploads.find((item) => item.role === role);
|
||||
}
|
||||
|
||||
function uploadListsEqual(left: PlaygroundUpload[], right: PlaygroundUpload[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((item, index) => {
|
||||
const next = right[index];
|
||||
return next && item.id === next.id && item.role === next.role && item.kind === next.kind;
|
||||
});
|
||||
}
|
||||
|
||||
function recordFromUnknown(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function newLocalId() {
|
||||
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@ -1158,6 +1158,40 @@
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.assistantUserMessage {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
max-width: min(560px, 78%);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.assistantUserImageGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
max-width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.assistantUserImagePreview {
|
||||
display: block;
|
||||
width: min(168px, 100%);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.assistantUserImagePreview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.assistantBubble {
|
||||
align-self: flex-start;
|
||||
width: fit-content;
|
||||
@ -1181,6 +1215,10 @@
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.assistantUserMessage .assistantBubble.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.assistantBubble.assistant {
|
||||
width: min(720px, 86%);
|
||||
margin-right: auto;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user