728 lines
27 KiB
TypeScript
728 lines
27 KiB
TypeScript
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();
|
||
}
|