easyai-ai-gateway/apps/web/src/pages/playground-chat.tsx

728 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}