225 lines
6.3 KiB
TypeScript
225 lines
6.3 KiB
TypeScript
import { useChat } from '@ai-sdk/react';
|
|
import { useSearchParams } from '@remix-run/react';
|
|
import { DefaultChatTransport, type FileUIPart } from 'ai';
|
|
import { animate } from 'framer-motion';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import type { ChatMessage } from '~/types/chat';
|
|
import type { ProgressAnnotation, UPageUIMessage } from '~/types/message';
|
|
import { cubicEasingFn } from '~/utils/easings';
|
|
import { createScopedLogger } from '~/utils/logger';
|
|
import { pagesToArtifacts } from '~/utils/page';
|
|
import {
|
|
getChatStarted,
|
|
setAborted,
|
|
setChatId,
|
|
setChatStarted,
|
|
setShowChat,
|
|
setStreamingState,
|
|
updateParseMessages,
|
|
} from '../stores/ai-state';
|
|
import { type SendChatMessageParams, setSendChatMessage } from '../stores/chat-message';
|
|
import { webBuilderStore } from '../stores/web-builder';
|
|
import { useChatUsage } from './useChatUsage';
|
|
import { useMessageParser } from './useMessageParser';
|
|
import { useProject } from './useProject';
|
|
|
|
const logger = createScopedLogger('useChatMessage');
|
|
|
|
export function useChatMessage({
|
|
initialId,
|
|
initialMessages,
|
|
}: {
|
|
initialId?: string;
|
|
initialMessages?: ChatMessage[];
|
|
}) {
|
|
const SAVE_PROJECT_DELAY_MS = 1000;
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const { saveProject } = useProject();
|
|
const { refreshUsageStats } = useChatUsage();
|
|
const { parsedMessages, parseMessages } = useMessageParser();
|
|
const [progressAnnotations, setProgressAnnotations] = useState<ProgressAnnotation[]>([]);
|
|
const { id, messages, status, stop, sendMessage } = useChat<UPageUIMessage>({
|
|
messages: initialMessages as unknown as UPageUIMessage[],
|
|
transport: new DefaultChatTransport({
|
|
api: '/api/chat',
|
|
prepareSendMessagesRequest({ messages, body }) {
|
|
return { body: { message: messages[messages.length - 1], ...body } };
|
|
},
|
|
}),
|
|
// 节流,每 50ms 渲染一次 messages。
|
|
experimental_throttle: 50,
|
|
onData: (dataPart) => {
|
|
if (dataPart.type === 'data-progress') {
|
|
addProgressMessage(dataPart.data);
|
|
}
|
|
},
|
|
onError: (e) => {
|
|
logger.error('Request failed\n\n', e.message);
|
|
toast.error('请求处理失败: ' + (e.message ? e.message : '没有返回详细信息'), { position: 'bottom-right' });
|
|
|
|
addStoppedProgressMessage('网络连接中断,响应已停止');
|
|
},
|
|
onFinish: ({ message }) => {
|
|
setTimeout(() => {
|
|
// 保存 editor project
|
|
saveProject(message.id);
|
|
}, SAVE_PROJECT_DELAY_MS);
|
|
refreshUsageStats();
|
|
logger.debug('Finished streaming');
|
|
},
|
|
});
|
|
|
|
const isLoading = useMemo(() => {
|
|
return status === 'streaming';
|
|
}, [status]);
|
|
|
|
const currentChatId = useMemo(() => {
|
|
return initialId || id;
|
|
}, [initialId, id]);
|
|
|
|
useEffect(() => {
|
|
setSendChatMessage(sendChatMessage);
|
|
if (initialMessages && initialMessages.length > 0) {
|
|
setShowChat(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
parseMessages(messages, isLoading);
|
|
const url = new URL(window.location.href);
|
|
url.pathname = `/chat/${currentChatId}`;
|
|
window.history.replaceState({}, '', url);
|
|
setChatId(currentChatId);
|
|
}
|
|
}, [messages, isLoading, parseMessages]);
|
|
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
updateParseMessages(messages, parsedMessages);
|
|
}
|
|
}, [parsedMessages]);
|
|
|
|
useEffect(() => {
|
|
setStreamingState(status === 'streaming');
|
|
if (status === 'submitted') {
|
|
setProgressAnnotations([]);
|
|
}
|
|
}, [status]);
|
|
|
|
const addProgressMessage = (progress: ProgressAnnotation) => {
|
|
setProgressAnnotations((prev) => [...prev, progress]);
|
|
};
|
|
|
|
const addStoppedProgressMessage = (message: string) => {
|
|
if (progressAnnotations.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const lastProgressMessage = progressAnnotations[progressAnnotations.length - 1];
|
|
const newProgressMessage = {
|
|
type: 'progress',
|
|
label: lastProgressMessage.label,
|
|
status: 'stopped',
|
|
order: lastProgressMessage.order + 1,
|
|
message,
|
|
} as ProgressAnnotation;
|
|
addProgressMessage(newProgressMessage);
|
|
};
|
|
|
|
const abort = () => {
|
|
stop();
|
|
setAborted(true);
|
|
webBuilderStore.chatStore.abortAllActions();
|
|
addStoppedProgressMessage('响应已中断');
|
|
logger.debug('Chat response aborted');
|
|
};
|
|
|
|
const runAnimation = async () => {
|
|
if (getChatStarted()) {
|
|
return;
|
|
}
|
|
|
|
await Promise.all([
|
|
animate('#examples', { opacity: 0, display: 'none' }, { duration: 0.1 }),
|
|
animate('#intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }),
|
|
]);
|
|
|
|
setChatStarted(true);
|
|
};
|
|
|
|
const fileToBase64 = (file: File): Promise<string | ArrayBuffer | null> => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
};
|
|
|
|
const filesToFileUIPart = async (files: File[]): Promise<FileUIPart[]> => {
|
|
const fileParts: FileUIPart[] = [];
|
|
|
|
await Promise.all(
|
|
files.map(async (file) => {
|
|
const base64 = await fileToBase64(file);
|
|
fileParts.push({
|
|
type: 'file',
|
|
mediaType: file.type,
|
|
filename: file.name,
|
|
url: base64 as string,
|
|
});
|
|
}),
|
|
);
|
|
return fileParts;
|
|
};
|
|
|
|
const sendChatMessage = async ({ messageContent, files, metadata }: SendChatMessageParams) => {
|
|
if (!messageContent?.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (isLoading) {
|
|
abort();
|
|
return;
|
|
}
|
|
|
|
const fileDataList = await filesToFileUIPart(files);
|
|
|
|
runAnimation();
|
|
|
|
const modifiedPages = webBuilderStore.pagesStore.getModifiedPages();
|
|
const sections = webBuilderStore.pagesStore.sections;
|
|
|
|
const userUpdateArtifact = modifiedPages !== undefined ? pagesToArtifacts(modifiedPages, sections) : '';
|
|
|
|
sendMessage(
|
|
{
|
|
text: modifiedPages !== undefined ? `${userUpdateArtifact}${messageContent}` : messageContent,
|
|
metadata,
|
|
files: fileDataList,
|
|
},
|
|
{
|
|
body: {
|
|
chatId: currentChatId,
|
|
rewindTo: searchParams.get('rewindTo'),
|
|
},
|
|
},
|
|
);
|
|
|
|
if (modifiedPages !== undefined) {
|
|
webBuilderStore.pagesStore.resetPageModifications();
|
|
}
|
|
};
|
|
|
|
return {
|
|
messages,
|
|
progressAnnotations,
|
|
isLoading,
|
|
abort,
|
|
sendChatMessage,
|
|
};
|
|
}
|