refactor: repartition server-side and client-side code
This commit is contained in:
10
app/.client/hooks/index.ts
Normal file
10
app/.client/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useAuth } from './useAuth';
|
||||
export { useDebugStatus } from './useDebugStatus';
|
||||
export * from './useEditChatDescription';
|
||||
export * from './useEditorCommands';
|
||||
export * from './useMessageParser';
|
||||
export { useNotifications } from './useNotifications';
|
||||
export * from './usePromptEnhancer';
|
||||
export * from './useShortcuts';
|
||||
export * from './useSnapScroll';
|
||||
export { default } from './useViewport';
|
||||
79
app/.client/hooks/useAuth.ts
Normal file
79
app/.client/hooks/useAuth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useFetcher, useRouteLoaderData } from '@remix-run/react';
|
||||
/*
|
||||
* 用户认证Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface UserInfo {
|
||||
sub?: string;
|
||||
name?: string;
|
||||
// 用户登录名,如果未启用用户名登录则可能为空
|
||||
username?: string;
|
||||
picture?: string;
|
||||
// 用户邮箱,可能为空
|
||||
email?: string;
|
||||
// 用户手机号,可能为空
|
||||
phone_number?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthUserResponse {
|
||||
isAuthenticated: boolean;
|
||||
claims?: UserInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth Hook - 获取和管理用户认证状态
|
||||
*
|
||||
* 优先使用根加载器数据,然后再进行客户端API请求
|
||||
*/
|
||||
export function useAuth() {
|
||||
// 尝试从根加载器获取数据
|
||||
const rootData = useRouteLoaderData<{ auth?: { isAuthenticated: boolean; userInfo: UserInfo | null } }>('root');
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(rootData?.auth?.isAuthenticated || false);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(rootData?.auth?.userInfo || null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!rootData?.auth);
|
||||
|
||||
const fetcher = useFetcher<AuthUserResponse>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootData?.auth && fetcher.state === 'idle' && !fetcher.data) {
|
||||
fetcher.load('/api/auth/user');
|
||||
}
|
||||
}, [fetcher, rootData]);
|
||||
|
||||
// 当获取数据后更新认证状态
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
setIsAuthenticated(fetcher.data.isAuthenticated);
|
||||
setUserInfo(fetcher.data.isAuthenticated ? fetcher.data.claims || null : null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// 登录
|
||||
const signIn = useCallback((callbackUrl = '/api/auth/callback') => {
|
||||
window.location.href = `/api/auth/sign-in?redirectTo=${encodeURIComponent(callbackUrl)}`;
|
||||
}, []);
|
||||
|
||||
// 登出
|
||||
const signOut = useCallback(() => {
|
||||
window.location.href = '/api/auth/sign-out';
|
||||
}, []);
|
||||
|
||||
// 刷新用户信息
|
||||
const refreshUserInfo = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
fetcher.load('/api/auth/user');
|
||||
}, [fetcher]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userInfo,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshUserInfo,
|
||||
};
|
||||
}
|
||||
18
app/.client/hooks/useChatDeployment.ts
Normal file
18
app/.client/hooks/useChatDeployment.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Deployment } from '@prisma/client';
|
||||
import { useLoaderData } from '@remix-run/react';
|
||||
import type { DeploymentPlatform } from '~/types/deployment';
|
||||
|
||||
/**
|
||||
* 仅支持 Chat 路由中的部署记录
|
||||
*/
|
||||
export function useChatDeployment() {
|
||||
const { deployments } = useLoaderData<{ deployments?: Deployment[] }>();
|
||||
|
||||
const getDeploymentByPlatform = (platform: DeploymentPlatform) => {
|
||||
return deployments?.find((deployment) => deployment.platform === platform);
|
||||
};
|
||||
|
||||
return {
|
||||
getDeploymentByPlatform,
|
||||
};
|
||||
}
|
||||
83
app/.client/hooks/useChatEntries.ts
Normal file
83
app/.client/hooks/useChatEntries.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { debounce } from '~/.client/utils/debounce';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
|
||||
export interface ServerChatListResponse {
|
||||
chats: ServerChatItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ServerChatItem {
|
||||
id: string;
|
||||
urlId?: string;
|
||||
description?: string;
|
||||
timestamp: string;
|
||||
lastMessage?: string;
|
||||
}
|
||||
|
||||
export function useChatEntries() {
|
||||
const chatListFetcher = useFetcher<ApiResponse<ServerChatListResponse>>();
|
||||
|
||||
const [lastFetchedQuery, setLastFetchedQuery] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<ServerChatItem[]>([]);
|
||||
|
||||
/**
|
||||
* 从后端调用接口查询列表数据
|
||||
* @param query 查询条件
|
||||
* @returns
|
||||
*/
|
||||
const loadServerChatEntries = useCallback(
|
||||
debounce((query = '') => {
|
||||
// 避免重复请求相同查询
|
||||
if (lastFetchedQuery === query && chatListFetcher.state === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLastFetchedQuery(query);
|
||||
|
||||
chatListFetcher.load(`/api/chat/list?q=${encodeURIComponent(query)}`);
|
||||
}, 300),
|
||||
[chatListFetcher, lastFetchedQuery],
|
||||
);
|
||||
|
||||
// 在 chatListFetcher 数据加载完成后处理结果
|
||||
useEffect(() => {
|
||||
if (chatListFetcher.state === 'idle' && chatListFetcher.data) {
|
||||
try {
|
||||
const { data } = chatListFetcher.data;
|
||||
const serverChats = data?.chats || [];
|
||||
setEntries(serverChats);
|
||||
} catch (error) {
|
||||
console.error('Error processing server chats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [chatListFetcher, chatListFetcher]);
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
* @param query 查询条件
|
||||
*/
|
||||
const loadChatEntries = useCallback(
|
||||
(query = '') => {
|
||||
// 从服务端加载搜索结果(如果搜索词长度>=2或为空)
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (query.length >= 2 || query === '') {
|
||||
loadServerChatEntries(query);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load chats from server:', error);
|
||||
}
|
||||
},
|
||||
[loadServerChatEntries],
|
||||
);
|
||||
|
||||
return { entries, isLoading, loadChatEntries };
|
||||
}
|
||||
75
app/.client/hooks/useChatHistory.ts
Normal file
75
app/.client/hooks/useChatHistory.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useLoaderData, useSearchParams } from '@remix-run/react';
|
||||
import { useCallback } from 'react';
|
||||
import type { Page, Section } from '~/types/actions';
|
||||
import type { ChatWithMessages } from '~/types/chat';
|
||||
import { useEditorStorage } from '../persistence/editor';
|
||||
|
||||
export interface ProjectData {
|
||||
pages?: Page[];
|
||||
sections?: Section[];
|
||||
projectData?: any;
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const { chat } = useLoaderData<{ chat?: ChatWithMessages }>();
|
||||
const { loadEditorProject } = useEditorStorage();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
/**
|
||||
* 加载项目数据,先从本地缓存中加载,如果本地缓存没有数据,则从服务器加载。
|
||||
*
|
||||
* @returns 项目数据。
|
||||
*/
|
||||
const getLoadProject = useCallback(async (): Promise<ProjectData | undefined> => {
|
||||
// 加载最新数据
|
||||
const pages = await loadEditorProject();
|
||||
if (pages) {
|
||||
return {
|
||||
pages,
|
||||
};
|
||||
}
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messages } = chat;
|
||||
if (!messages || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 返回特定消息 ID 的项目数据
|
||||
const currentMessageId = searchParams.get('rewindTo');
|
||||
if (currentMessageId) {
|
||||
const data = messages?.find((message) => message.id === currentMessageId);
|
||||
const pages = data?.page?.pages;
|
||||
if (pages) {
|
||||
return {
|
||||
pages: pages as unknown as Page[],
|
||||
};
|
||||
}
|
||||
}
|
||||
// 没有指定消息 ID,返回最新的项目数据
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.page) {
|
||||
return {
|
||||
pages: lastMessage.page.pages as unknown as Page[],
|
||||
};
|
||||
}
|
||||
}, [chat, searchParams]);
|
||||
|
||||
/**
|
||||
* 获取聊天最新描述
|
||||
* @param chatId
|
||||
* @returns
|
||||
*/
|
||||
const getChatLatestDescription = useCallback(() => {
|
||||
if (!chat) {
|
||||
return '';
|
||||
}
|
||||
return chat.description || '';
|
||||
}, [chat]);
|
||||
return {
|
||||
getLoadProject,
|
||||
getChatLatestDescription,
|
||||
};
|
||||
}
|
||||
232
app/.client/hooks/useChatMessage.ts
Normal file
232
app/.client/hooks/useChatMessage.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { useStore } from '@nanostores/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 {
|
||||
aiState,
|
||||
getChatStarted,
|
||||
setAborted,
|
||||
setChatId,
|
||||
setChatStarted,
|
||||
setShowChat,
|
||||
setStreamingState,
|
||||
updateParseMessages,
|
||||
} from '~/.client/stores/ai-state';
|
||||
import { type SendChatMessageParams, setSendChatMessage } from '~/.client/stores/chat-message';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import { cubicEasingFn } from '~/.client/utils/easings';
|
||||
import { pagesToArtifacts } from '~/.client/utils/page';
|
||||
import type { ChatMessage } from '~/types/chat';
|
||||
import type { ProgressAnnotation, UPageUIMessage } from '~/types/message';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
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 { chatStarted } = useStore(aiState);
|
||||
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);
|
||||
}
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatId && chatStarted) {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `/chat/${currentChatId}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
setChatId(currentChatId);
|
||||
}
|
||||
}, [currentChatId, chatStarted]);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
186
app/.client/hooks/useChatOperate.ts
Normal file
186
app/.client/hooks/useChatOperate.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useFetcher, useNavigate } from '@remix-run/react';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
deleteEditorProject,
|
||||
duplicateEditorProject,
|
||||
forkEditorProject,
|
||||
openEditorDatabase,
|
||||
} from '~/.client/persistence/editor';
|
||||
import { getChatId } from '~/.client/stores/ai-state';
|
||||
import { useProject } from './useProject';
|
||||
|
||||
export const editorDb = await openEditorDatabase();
|
||||
|
||||
export function useChatOperate() {
|
||||
const navigate = useNavigate();
|
||||
const deleteChatFetcher = useFetcher();
|
||||
const updateChatFetcher = useFetcher();
|
||||
|
||||
const { forkChat: forkRemoteChat } = useProject();
|
||||
/**
|
||||
* 聊天分叉功能
|
||||
*
|
||||
* @param chatId 要复制的聊天ID
|
||||
* @param messageId 消息ID,指定复制到哪条消息为止
|
||||
* @returns 新聊天的ID
|
||||
*/
|
||||
const forkMessage = async (chatId: string, messageId: string) => {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
// 后端 fork 聊天信息,并返回新的聊天 ID
|
||||
const newId = await forkRemoteChat(chatId, messageId);
|
||||
|
||||
// 前端 fork editor 项目信息
|
||||
if (newId && editorDb) {
|
||||
await forkEditorProject(editorDb, chatId, messageId, newId);
|
||||
}
|
||||
|
||||
return newId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 复制聊天
|
||||
*
|
||||
* @param listItemId 聊天 ID,如果不提供则复制当前聊天
|
||||
* @returns
|
||||
*/
|
||||
const duplicateCurrentChat = async (chatId?: string) => {
|
||||
if (!chatId && !getChatId()) {
|
||||
return;
|
||||
}
|
||||
const duplicateChatId = (chatId || getChatId()) as string;
|
||||
try {
|
||||
const newId = await forkRemoteChat(duplicateChatId);
|
||||
if (newId && editorDb) {
|
||||
await duplicateEditorProject(editorDb, duplicateChatId, newId);
|
||||
}
|
||||
navigate(`/chat/${newId}`, { replace: true });
|
||||
toast.success('聊天复制成功');
|
||||
} catch (error) {
|
||||
toast.error('复制聊天失败');
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据聊天 ID 删除聊天
|
||||
* @param chatId 聊天 ID
|
||||
* @returns
|
||||
*/
|
||||
const deleteChat = async (chatId: string): Promise<void> => {
|
||||
try {
|
||||
// 尝试通过API删除
|
||||
deleteChatFetcher.submit({ chatId }, { method: 'POST', action: '/api/chat/delete' });
|
||||
|
||||
// 同时从本地删除
|
||||
if (editorDb) {
|
||||
await deleteEditorProject(editorDb, chatId);
|
||||
}
|
||||
|
||||
console.log('Successfully deleted chat:', chatId);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete chat:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据选择的聊天 ID 批量删除聊天
|
||||
* @param itemsToDeleteIds 要删除的聊天 ID 列表
|
||||
* @returns
|
||||
*/
|
||||
const deleteSelectedItems = async (chatIds: string[]) => {
|
||||
if (chatIds.length === 0) {
|
||||
console.log('跳过批量删除: 没有要删除的聊天');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`开始批量删除 ${chatIds.length} 个聊天`, chatIds);
|
||||
|
||||
// 通过 API 删除多个聊天
|
||||
deleteChatFetcher.submit({ ids: JSON.stringify(chatIds) }, { method: 'POST', action: '/api/chat/delete' });
|
||||
|
||||
// 同时从本地删除
|
||||
if (editorDb) {
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const id of chatIds) {
|
||||
try {
|
||||
await deleteEditorProject(editorDb, id);
|
||||
|
||||
deletedCount++;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting local chat ${id}:`, error);
|
||||
errors.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 日志本地删除结果
|
||||
if (errors.length === 0) {
|
||||
console.log(`Local deletion: ${deletedCount} chats deleted successfully`);
|
||||
} else {
|
||||
console.warn(`Local deletion: ${deletedCount} chats deleted. ${errors.length} failed.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过API更新聊天描述
|
||||
* @param chatId 待更新的聊天 ID
|
||||
* @param description 更新后的描述
|
||||
* @returns
|
||||
*/
|
||||
const updateDescriptionViaApi = useCallback(
|
||||
async (chatId: string, description: string): Promise<boolean> => {
|
||||
try {
|
||||
// 使用表单格式提交数据
|
||||
updateChatFetcher.submit(
|
||||
{
|
||||
id: chatId,
|
||||
description: description,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/chat/update',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update description via API:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateChatFetcher],
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新 Chat 描述
|
||||
* @param chatId 待更新的聊天 ID
|
||||
* @param description 更新后的描述
|
||||
*/
|
||||
const updateChatDescription = async (description: string, chatId?: string) => {
|
||||
const id = chatId || getChatId();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateDescriptionViaApi(id, description);
|
||||
} catch (error) {
|
||||
toast.error('更新聊天描述失败: ' + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
updateChatFetcher,
|
||||
deleteChatFetcher,
|
||||
deleteChat,
|
||||
deleteSelectedItems,
|
||||
forkMessage,
|
||||
duplicateCurrentChat,
|
||||
updateChatDescription,
|
||||
};
|
||||
}
|
||||
70
app/.client/hooks/useChatUsage.ts
Normal file
70
app/.client/hooks/useChatUsage.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRevalidator, useRouteLoaderData } from '@remix-run/react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
/**
|
||||
* 聊天使用量统计类型定义
|
||||
*/
|
||||
export interface ChatUsageStats {
|
||||
total: {
|
||||
_sum: {
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
};
|
||||
_count: number;
|
||||
};
|
||||
byStatus: Array<{
|
||||
status: string;
|
||||
_count: number;
|
||||
_sum: {
|
||||
totalTokens: number | null;
|
||||
};
|
||||
}>;
|
||||
byChat: Array<{
|
||||
chatId: string;
|
||||
_count: number;
|
||||
_sum: {
|
||||
totalTokens: number | null;
|
||||
};
|
||||
}>;
|
||||
byDate: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
totalTokens: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useChatUsage Hook - 获取用户聊天使用量统计
|
||||
*/
|
||||
export function useChatUsage() {
|
||||
const rootData = useRouteLoaderData<{ chatUsage?: ChatUsageStats }>('root');
|
||||
const { isAuthenticated } = useAuth();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const usageStats = isAuthenticated ? rootData?.chatUsage || null : null;
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 刷新聊天使用统计数据
|
||||
* 通过 Remix 的 revalidator 重新验证根路由数据
|
||||
*/
|
||||
const refreshUsageStats = () => {
|
||||
setIsLoading(true);
|
||||
revalidator.revalidate();
|
||||
setIsLoading(revalidator.state === 'loading');
|
||||
};
|
||||
|
||||
// 当 revalidator 状态变化时更新 loading 状态
|
||||
if (revalidator.state === 'idle' && isLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return {
|
||||
usageStats,
|
||||
isLoading: isLoading || revalidator.state === 'loading',
|
||||
refreshUsageStats,
|
||||
};
|
||||
}
|
||||
89
app/.client/hooks/useDebugStatus.ts
Normal file
89
app/.client/hooks/useDebugStatus.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { acknowledgeError, acknowledgeWarning, type DebugIssue, getDebugStatus } from '~/.client/api/debug';
|
||||
|
||||
const ACKNOWLEDGED_DEBUG_ISSUES_KEY = 'upage_acknowledged_debug_issues';
|
||||
|
||||
const getAcknowledgedIssues = (): string[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const setAcknowledgedIssues = (issueIds: string[]) => {
|
||||
try {
|
||||
localStorage.setItem(ACKNOWLEDGED_DEBUG_ISSUES_KEY, JSON.stringify(issueIds));
|
||||
} catch (error) {
|
||||
console.error('Failed to persist acknowledged debug issues:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useDebugStatus = () => {
|
||||
const [hasActiveWarnings, setHasActiveWarnings] = useState(false);
|
||||
const [activeIssues, setActiveIssues] = useState<DebugIssue[]>([]);
|
||||
const [acknowledgedIssueIds, setAcknowledgedIssueIds] = useState<string[]>(() => getAcknowledgedIssues());
|
||||
|
||||
const checkDebugStatus = async () => {
|
||||
try {
|
||||
const status = await getDebugStatus();
|
||||
const issues: DebugIssue[] = [
|
||||
...status.warnings.map((w) => ({ ...w, type: 'warning' as const })),
|
||||
...status.errors.map((e) => ({ ...e, type: 'error' as const })),
|
||||
].filter((issue) => !acknowledgedIssueIds.includes(issue.id));
|
||||
|
||||
setActiveIssues(issues);
|
||||
setHasActiveWarnings(issues.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to check debug status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check immediately and then every 5 seconds
|
||||
checkDebugStatus();
|
||||
|
||||
const interval = setInterval(checkDebugStatus, 5 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [acknowledgedIssueIds]);
|
||||
|
||||
const acknowledgeIssue = async (issue: DebugIssue) => {
|
||||
try {
|
||||
if (issue.type === 'warning') {
|
||||
await acknowledgeWarning(issue.id);
|
||||
} else {
|
||||
await acknowledgeError(issue.id);
|
||||
}
|
||||
|
||||
const newAcknowledgedIds = [...acknowledgedIssueIds, issue.id];
|
||||
setAcknowledgedIssueIds(newAcknowledgedIds);
|
||||
setAcknowledgedIssues(newAcknowledgedIds);
|
||||
setActiveIssues((prev) => prev.filter((i) => i.id !== issue.id));
|
||||
setHasActiveWarnings(activeIssues.length > 1);
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge issue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledgeAllIssues = async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
activeIssues.map((issue) =>
|
||||
issue.type === 'warning' ? acknowledgeWarning(issue.id) : acknowledgeError(issue.id),
|
||||
),
|
||||
);
|
||||
|
||||
const newAcknowledgedIds = [...acknowledgedIssueIds, ...activeIssues.map((i) => i.id)];
|
||||
setAcknowledgedIssueIds(newAcknowledgedIds);
|
||||
setAcknowledgedIssues(newAcknowledgedIds);
|
||||
setActiveIssues([]);
|
||||
setHasActiveWarnings(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to acknowledge all issues:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { hasActiveWarnings, activeIssues, acknowledgeIssue, acknowledgeAllIssues };
|
||||
};
|
||||
228
app/.client/hooks/useDeploymentRecords.ts
Normal file
228
app/.client/hooks/useDeploymentRecords.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useRevalidator } from '@remix-run/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { DeploymentPlatform } from '~/types/deployment';
|
||||
import { DeploymentPlatformEnum } from '~/types/deployment';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
export interface DeploymentRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
chatId: string;
|
||||
platform: string;
|
||||
deploymentId: string;
|
||||
url: string;
|
||||
status: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
chat?: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeploymentStats {
|
||||
totalSites: number;
|
||||
totalDays: number;
|
||||
totalVisits: number;
|
||||
totalBytes?: number;
|
||||
lastAccess?: string | null;
|
||||
sitesByPlatform?: Record<string, number>;
|
||||
}
|
||||
|
||||
const platformEndpoints: Record<string, string> = {
|
||||
[DeploymentPlatformEnum._1PANEL]: '/api/1panel',
|
||||
[DeploymentPlatformEnum.NETLIFY]: '/api/netlify',
|
||||
[DeploymentPlatformEnum.VERCEL]: '/api/vercel',
|
||||
};
|
||||
function getPlatformEndpoint(platform: string, action: string): string {
|
||||
const baseEndpoint = platformEndpoints[platform];
|
||||
if (!baseEndpoint) {
|
||||
throw new Error(`不支持的平台: ${platform}`);
|
||||
}
|
||||
return `${baseEndpoint}/${action}`;
|
||||
}
|
||||
|
||||
export function useDeploymentRecords() {
|
||||
const { revalidate } = useRevalidator();
|
||||
const { userInfo, isAuthenticated } = useAuth();
|
||||
const [deploymentRecords, setDeploymentRecords] = useState<Record<string, DeploymentRecord[]>>({});
|
||||
const [totals, setTotals] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [loadingPlatforms, setLoadingPlatforms] = useState<Record<string, boolean>>({});
|
||||
const [stats, setStats] = useState<DeploymentStats>({
|
||||
totalSites: 0,
|
||||
totalDays: 30,
|
||||
totalVisits: 0,
|
||||
sitesByPlatform: {},
|
||||
});
|
||||
|
||||
const loadPlatformRecords = useCallback(
|
||||
async ({ offset = 0, limit = 10, platform }: { offset?: number; limit?: number; platform: DeploymentPlatform }) => {
|
||||
if (!isAuthenticated || !userInfo?.sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
setLoadingPlatforms((prev) => ({ ...prev, [platform]: true }));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/deployments?offset=${offset}&limit=${limit}&platform=${platform}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deployment records');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { deployments } = responseData.data;
|
||||
|
||||
setDeploymentRecords((prev) => ({
|
||||
...prev,
|
||||
[platform]: offset === 0 ? deployments : [...(prev[platform] || []), ...deployments],
|
||||
}));
|
||||
|
||||
setTotals((prev) => ({
|
||||
...prev,
|
||||
[platform]: offset === 0 ? deployments.length : prev[platform] + deployments.length,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading deployment records:', error);
|
||||
} finally {
|
||||
if (platform) {
|
||||
setLoadingPlatforms((prev) => ({ ...prev, [platform]: false }));
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAuthenticated, userInfo],
|
||||
);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!isAuthenticated || !userInfo?.sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setStats({
|
||||
totalSites: 0,
|
||||
totalDays: 30,
|
||||
totalVisits: 0,
|
||||
sitesByPlatform: {},
|
||||
});
|
||||
|
||||
const response = await fetch('/api/deployments/stats');
|
||||
if (response.ok) {
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
return;
|
||||
}
|
||||
const data = responseData.data;
|
||||
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
totalSites: data.totalSites || 0,
|
||||
totalDays: data.totalDays || 30,
|
||||
totalVisits: data.totalVisits || 0,
|
||||
totalBytes: data.totalBytes || 0,
|
||||
lastAccess: data.lastAccess || null,
|
||||
sitesByPlatform: data.sitesByPlatform || {},
|
||||
}));
|
||||
|
||||
if (data.sitesByPlatform) {
|
||||
setTotals((prev) => ({
|
||||
...prev,
|
||||
...data.sitesByPlatform,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading deployment stats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, userInfo]);
|
||||
|
||||
const refreshDeploymentRecords = useCallback(() => {
|
||||
loadStats();
|
||||
for (const platform of Object.values(DeploymentPlatformEnum)) {
|
||||
loadPlatformRecords({ platform });
|
||||
}
|
||||
}, [loadStats, loadPlatformRecords]);
|
||||
|
||||
const toggleAccess = useCallback(
|
||||
async (id: string, platform: string) => {
|
||||
try {
|
||||
const endpoint = getPlatformEndpoint(platform, 'toggle-access');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '操作失败');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
throw new Error(responseData.message || '操作失败');
|
||||
}
|
||||
|
||||
return responseData.data;
|
||||
} catch (error) {
|
||||
console.error('切换访问状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[isAuthenticated, userInfo],
|
||||
);
|
||||
|
||||
const deletePage = useCallback(async (id: string, platform: string) => {
|
||||
try {
|
||||
const endpoint = getPlatformEndpoint(platform, 'delete');
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '删除失败');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
if (!responseData.success) {
|
||||
throw new Error(responseData.message || '删除失败');
|
||||
}
|
||||
|
||||
revalidate();
|
||||
return responseData.data;
|
||||
} catch (error) {
|
||||
console.error('删除部署失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
deploymentRecords,
|
||||
totals,
|
||||
stats,
|
||||
isLoading,
|
||||
loadingPlatforms,
|
||||
loadPlatformRecords,
|
||||
refreshDeploymentRecords,
|
||||
isPlatformLoading: (platform: string) => loadingPlatforms[platform] || false,
|
||||
toggleAccess,
|
||||
deletePage,
|
||||
};
|
||||
}
|
||||
126
app/.client/hooks/useEditChatDescription.ts
Normal file
126
app/.client/hooks/useEditChatDescription.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useChatOperate } from './useChatOperate';
|
||||
|
||||
interface EditChatDescriptionOptions {
|
||||
initialDescription: string;
|
||||
chatId?: string;
|
||||
}
|
||||
|
||||
type EditChatDescriptionHook = {
|
||||
editing: boolean;
|
||||
setCurrentDescription: (description: string) => void;
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleBlur: () => Promise<void>;
|
||||
handleSubmit: (event: React.FormEvent) => Promise<void>;
|
||||
handleKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => Promise<void>;
|
||||
currentDescription: string | undefined;
|
||||
toggleEditMode: () => void;
|
||||
updateChatDescription: (description: string, chatId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage the state and behavior for editing chat descriptions.
|
||||
*
|
||||
* Offers functions to:
|
||||
* - Switch between edit and view modes.
|
||||
* - Manage input changes, blur, and form submission events.
|
||||
* - Save updates to backend API, fallback to IndexedDB and optionally to the global application state.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.initialDescription - The current chat description.
|
||||
* @param {string} options.customChatId - Optional ID for updating the description via the sidebar.
|
||||
* @returns {EditChatDescriptionHook} Methods and state for managing description edits.
|
||||
*/
|
||||
export function useEditChatDescription({
|
||||
initialDescription,
|
||||
chatId,
|
||||
}: EditChatDescriptionOptions): EditChatDescriptionHook {
|
||||
const { updateChatDescription } = useChatOperate();
|
||||
// 从 messages 中获取到的描述
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [currentDescription, setCurrentDescription] = useState(initialDescription);
|
||||
|
||||
const toggleEditMode = useCallback(() => setEditing((prev) => !prev), []);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentDescription(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(async () => {
|
||||
setCurrentDescription(initialDescription);
|
||||
toggleEditMode();
|
||||
}, [toggleEditMode]);
|
||||
|
||||
const isValidDescription = useCallback(
|
||||
(desc: string): boolean => {
|
||||
const trimmedDesc = desc.trim();
|
||||
|
||||
if (trimmedDesc === initialDescription) {
|
||||
toggleEditMode();
|
||||
return false; // No change, skip validation
|
||||
}
|
||||
|
||||
const lengthValid = trimmedDesc.length > 0 && trimmedDesc.length <= 100;
|
||||
|
||||
// 允许中文字符、字母、数字、空格和常见标点符号,排除可能引起问题的字符
|
||||
const characterValid = /^[\u4e00-\u9fa5a-zA-Z0-9\s\-_.,!?()[\]{}'"]+$/.test(trimmedDesc);
|
||||
|
||||
if (!lengthValid) {
|
||||
toast.error('描述必须介于 1 和 100 个字符之间。');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!characterValid) {
|
||||
toast.error('描述只能包含字母、数字、空格和基本标点符号。');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[initialDescription, toggleEditMode],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!isValidDescription(currentDescription!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currentDescription) {
|
||||
return;
|
||||
}
|
||||
updateChatDescription(currentDescription!, chatId);
|
||||
} catch (error) {
|
||||
toast.error('更新聊天描述失败: ' + (error as Error).message);
|
||||
}
|
||||
|
||||
toggleEditMode();
|
||||
},
|
||||
[currentDescription, chatId, toggleEditMode, updateChatDescription, isValidDescription],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
await handleBlur();
|
||||
}
|
||||
},
|
||||
[handleBlur],
|
||||
);
|
||||
|
||||
return {
|
||||
editing,
|
||||
setCurrentDescription,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
currentDescription,
|
||||
toggleEditMode,
|
||||
updateChatDescription,
|
||||
};
|
||||
}
|
||||
53
app/.client/hooks/useEditorCommands.ts
Normal file
53
app/.client/hooks/useEditorCommands.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type RefObject, useCallback, useEffect } from 'react';
|
||||
import { editorCommands } from '~/.client/stores/editor';
|
||||
import type { Editor } from '~/types/editor';
|
||||
import { logger } from '~/utils/logger';
|
||||
|
||||
/**
|
||||
* 用于监听编辑器命令的自定义 hook
|
||||
* @param editorRef 编辑器实例引用
|
||||
* @returns 包含处理特定元素的方法
|
||||
*/
|
||||
export function useEditorCommands(editorRef: RefObject<Editor | null>) {
|
||||
// 处理滚动到指定元素
|
||||
const scrollToElement = useCallback(
|
||||
(domId: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.scrollToElement(`#${domId}`);
|
||||
},
|
||||
[editorRef],
|
||||
);
|
||||
|
||||
// 监听编辑器命令
|
||||
useEffect(() => {
|
||||
const unsubscribe = editorCommands.listen((command) => {
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command.type) {
|
||||
case 'scrollToElement': {
|
||||
const { domId } = command.payload;
|
||||
scrollToElement(domId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn('未知的编辑器命令类型', command);
|
||||
}
|
||||
|
||||
// 处理完命令后重置
|
||||
editorCommands.set(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [scrollToElement]);
|
||||
|
||||
return {
|
||||
scrollToElement,
|
||||
};
|
||||
}
|
||||
106
app/.client/hooks/useMessageParser.ts
Normal file
106
app/.client/hooks/useMessageParser.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { StreamingMessageParser } from '~/.client/runtime/message-parser';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import type { UPageUIMessage } from '~/types/message';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('useMessageParser');
|
||||
|
||||
const chatStore = webBuilderStore.chatStore;
|
||||
const messageParser = new StreamingMessageParser({
|
||||
callbacks: {
|
||||
onArtifactOpen: (data) => {
|
||||
logger.trace('onArtifactOpen', data);
|
||||
|
||||
webBuilderStore.showWorkbench.set(true);
|
||||
chatStore.addArtifact(data);
|
||||
chatStore.setCurrentMessageId(data.messageId);
|
||||
},
|
||||
onArtifactClose: (data) => {
|
||||
logger.trace('onArtifactClose');
|
||||
|
||||
chatStore.updateArtifact(data, { closed: true });
|
||||
},
|
||||
onActionOpen: (data) => {
|
||||
logger.trace('onActionOpen', data.action);
|
||||
chatStore.addAction(data);
|
||||
},
|
||||
onActionStream: (data) => {
|
||||
logger.trace('onActionStream', data.action);
|
||||
chatStore.runAction(data, true);
|
||||
},
|
||||
onActionClose: (data) => {
|
||||
logger.trace('onActionClose', data.action);
|
||||
chatStore.addAction(data);
|
||||
chatStore.runAction(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
const extractTextContent = (message: UPageUIMessage) =>
|
||||
message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
|
||||
export function useMessageParser() {
|
||||
const [parsedMessages, setParsedMessages] = useState<{ [key: number]: string }>({});
|
||||
const messageIdMap = useRef<Map<number, string>>(new Map());
|
||||
|
||||
const parseMessages = useCallback((messages: UPageUIMessage[], isLoading: boolean) => {
|
||||
let reset = false;
|
||||
|
||||
if (import.meta.env.DEV && !isLoading) {
|
||||
reset = true;
|
||||
messageParser.reset();
|
||||
}
|
||||
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (message.role === 'assistant' || message.role === 'user') {
|
||||
if (!messageIdMap.current.has(index)) {
|
||||
messageIdMap.current.set(index, message.id);
|
||||
}
|
||||
// 当对应位置的 message id 发生变化时,重置解析
|
||||
if (messageIdMap.current.get(index) !== message.id) {
|
||||
reset = true;
|
||||
messageParser.reset();
|
||||
messageIdMap.current.set(index, message.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const textContent = extractTextContent(message);
|
||||
// 检查消息内容是否存在
|
||||
if (textContent === undefined || textContent === null) {
|
||||
logger.warn(`Message ${message.id} has no text content`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析消息内容
|
||||
const newParsedContent = messageParser.parse(message.id, textContent);
|
||||
if (!newParsedContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新解析后的消息
|
||||
setParsedMessages((prevParsed) => {
|
||||
const updatedContent = !reset ? (prevParsed[index] || '') + newParsedContent : newParsedContent;
|
||||
return {
|
||||
...prevParsed,
|
||||
[index]: updatedContent,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// 捕获并记录解析过程中的错误
|
||||
logger.error(`Error parsing message ${message.id}:`, error);
|
||||
|
||||
// 出错时保留原始消息内容
|
||||
setParsedMessages((prevParsed) => ({
|
||||
...prevParsed,
|
||||
[index]: extractTextContent(message),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { parsedMessages, parseMessages };
|
||||
}
|
||||
51
app/.client/hooks/useNotifications.ts
Normal file
51
app/.client/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getNotifications, markNotificationRead, type Notification } from '~/.client/api/notifications';
|
||||
import { logStore } from '~/stores/logs';
|
||||
|
||||
export const useNotifications = () => {
|
||||
const [hasUnreadNotifications, setHasUnreadNotifications] = useState(false);
|
||||
const [unreadNotifications, setUnreadNotifications] = useState<Notification[]>([]);
|
||||
const logs = useStore(logStore.logs);
|
||||
|
||||
const checkNotifications = async () => {
|
||||
try {
|
||||
const notifications = await getNotifications();
|
||||
const unread = notifications.filter((n) => !logStore.isRead(n.id));
|
||||
setUnreadNotifications(unread);
|
||||
setHasUnreadNotifications(unread.length > 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to check notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check immediately and then every minute
|
||||
checkNotifications();
|
||||
|
||||
const interval = setInterval(checkNotifications, 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [logs]); // Re-run when logs change
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await markNotificationRead(notificationId);
|
||||
await checkNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
const notifications = await getNotifications();
|
||||
await Promise.all(notifications.map((n) => markNotificationRead(n.id)));
|
||||
await checkNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all notifications as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { hasUnreadNotifications, unreadNotifications, markAsRead, markAllAsRead };
|
||||
};
|
||||
129
app/.client/hooks/useProject.ts
Normal file
129
app/.client/hooks/useProject.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useFetcher } from '@remix-run/react';
|
||||
import { useEditorStorage } from '~/.client/persistence/editor';
|
||||
import { webBuilderStore } from '~/.client/stores/web-builder';
|
||||
import type { ApiResponse } from '~/types/global';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('useGrapesProject');
|
||||
|
||||
export function useProject() {
|
||||
const fetcher = useFetcher();
|
||||
const { saveEditorProject } = useEditorStorage();
|
||||
|
||||
/**
|
||||
* 保存项目数据到后端数据库
|
||||
*
|
||||
* @param messageId 消息ID
|
||||
* @param projectData GrapesJS项目数据
|
||||
* @param sections 页面区块数据
|
||||
* @returns 保存是否成功
|
||||
*/
|
||||
async function saveProject(messageId: string) {
|
||||
if (!messageId) {
|
||||
logger.error('保存项目失败: 消息ID不能为空');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存之前,先保存所有页面
|
||||
await webBuilderStore.saveAllPages();
|
||||
const projectPages = Object.values(webBuilderStore.pagesStore.pages.get()).filter((page) => page !== undefined);
|
||||
const projectSections = Object.values(webBuilderStore.pagesStore.sections.get())
|
||||
.filter((section) => section !== undefined)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
actionId: section.id,
|
||||
}));
|
||||
if (projectPages.length === 0 || projectSections.length === 0) {
|
||||
logger.error('保存项目失败: 页面或 Section 不能为空');
|
||||
return false;
|
||||
}
|
||||
const isConsistent = projectPages.every((page) => {
|
||||
const actionIds = page.actionIds;
|
||||
const content = page.content;
|
||||
if (actionIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!isConsistent) {
|
||||
logger.error(
|
||||
'保存项目失败: 页面内容与 actions 不一致',
|
||||
JSON.stringify({
|
||||
projectPages,
|
||||
projectSections,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// 先保存在本地数据中
|
||||
saveEditorProject(messageId, projectPages, projectSections);
|
||||
// 再调用远程接口保存到后端数据库
|
||||
// 使用fetcher调用API保存项目数据
|
||||
fetcher.submit(
|
||||
{
|
||||
messageId,
|
||||
pages: JSON.stringify(projectPages),
|
||||
sections: JSON.stringify(projectSections),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
action: '/api/project',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('保存GrapesJS项目失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制聊天及其相关内容(消息、GrapesJS项目数据和区块)
|
||||
*
|
||||
* @param chatId 要复制的聊天ID
|
||||
* @param messageId 可选参数,当提供时只复制到该消息为止的消息(包含该消息);不提供时复制整个聊天
|
||||
* @returns 成功时返回新聊天的ID,失败时返回undefined
|
||||
*/
|
||||
async function forkChat(chatId: string, messageId?: string) {
|
||||
if (!chatId) {
|
||||
logger.error('复制聊天失败: 聊天ID不能为空');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端API复制聊天
|
||||
const response = await fetch('/api/chat/fork', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceChatId: chatId,
|
||||
messageId,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data, success, message } = (await response.json()) as ApiResponse<string>;
|
||||
|
||||
if (!response.ok || !success) {
|
||||
logger.error('复制聊天失败:', message);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.info(`成功复制聊天 ${chatId},新聊天ID: ${data}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('复制聊天过程中发生错误:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveProject,
|
||||
forkChat,
|
||||
};
|
||||
}
|
||||
50
app/.client/hooks/usePromptEnhancer.ts
Normal file
50
app/.client/hooks/usePromptEnhancer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport, type UIMessage } from 'ai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('usePromptEnhancement');
|
||||
|
||||
export function usePromptEnhancer() {
|
||||
const { messages, sendMessage } = useChat<UIMessage>({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/enhancer',
|
||||
}),
|
||||
onError: (error) => {
|
||||
logger.error('Error enhancing prompt:', error);
|
||||
toast.error('提示词优化失败');
|
||||
},
|
||||
onFinish: () => {
|
||||
setIsLoading(false);
|
||||
toast.success('提示词优化成功');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const content = lastMessage.parts.find((part) => part.type === 'text')?.text;
|
||||
setEnhancedInput(content || '');
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [enhancedInput, setEnhancedInput] = useState('');
|
||||
|
||||
const resetEnhancer = () => {
|
||||
setIsLoading(false);
|
||||
setEnhancedInput('');
|
||||
};
|
||||
|
||||
const enhancePrompt = async (originalInput: string) => {
|
||||
setIsLoading(true);
|
||||
sendMessage({
|
||||
text: originalInput,
|
||||
});
|
||||
};
|
||||
|
||||
return { enhancedInput, isLoading, enhancePrompt, resetEnhancer };
|
||||
}
|
||||
148
app/.client/hooks/useSettings.ts
Normal file
148
app/.client/hooks/useSettings.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { TabVisibilityConfig, TabWindowConfig } from '~/.client/components/@settings/core/types';
|
||||
import { getLocalStorage, setLocalStorage } from '~/.client/persistence';
|
||||
import {
|
||||
isDebugMode,
|
||||
isEventLogsEnabled,
|
||||
latestBranchStore,
|
||||
promptStore,
|
||||
resetTabConfiguration as resetTabConfig,
|
||||
tabConfigurationStore,
|
||||
updateEventLogs,
|
||||
updateLatestBranch,
|
||||
updatePromptId,
|
||||
updateTabConfiguration as updateTabConfig,
|
||||
} from '~/.client/stores/settings';
|
||||
import { logStore } from '~/stores/logs';
|
||||
|
||||
export interface Settings {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: string;
|
||||
notifications: boolean;
|
||||
eventLogs: boolean;
|
||||
timezone: string;
|
||||
tabConfiguration: TabWindowConfig;
|
||||
}
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
// Theme and UI settings
|
||||
setTheme: (theme: Settings['theme']) => void;
|
||||
setLanguage: (language: string) => void;
|
||||
setNotifications: (enabled: boolean) => void;
|
||||
setEventLogs: (enabled: boolean) => void;
|
||||
setTimezone: (timezone: string) => void;
|
||||
settings: Settings;
|
||||
|
||||
// Debug and development settings
|
||||
debug: boolean;
|
||||
enableDebugMode: (enabled: boolean) => void;
|
||||
eventLogs: boolean;
|
||||
promptId: string;
|
||||
setPromptId: (promptId: string) => void;
|
||||
isLatestBranch: boolean;
|
||||
enableLatestBranch: (enabled: boolean) => void;
|
||||
|
||||
// Tab configuration
|
||||
tabConfiguration: TabWindowConfig;
|
||||
updateTabConfiguration: (config: TabVisibilityConfig) => void;
|
||||
resetTabConfiguration: () => void;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const debug = useStore(isDebugMode);
|
||||
const eventLogs = useStore(isEventLogsEnabled);
|
||||
const promptId = useStore(promptStore);
|
||||
const isLatestBranch = useStore(latestBranchStore);
|
||||
const tabConfiguration = useStore(tabConfigurationStore);
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
const storedSettings = getLocalStorage('settings');
|
||||
return {
|
||||
theme: storedSettings?.theme || 'system',
|
||||
language: storedSettings?.language || 'en',
|
||||
notifications: storedSettings?.notifications ?? true,
|
||||
eventLogs: storedSettings?.eventLogs ?? true,
|
||||
timezone: storedSettings?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tabConfiguration,
|
||||
};
|
||||
});
|
||||
|
||||
const saveSettings = useCallback((newSettings: Partial<Settings>) => {
|
||||
setSettings((prev) => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
setLocalStorage('settings', updated);
|
||||
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const enableDebugMode = useCallback((enabled: boolean) => {
|
||||
isDebugMode.set(enabled);
|
||||
logStore.logSystem(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
|
||||
Cookies.set('isDebugEnabled', String(enabled));
|
||||
}, []);
|
||||
|
||||
const setEventLogs = useCallback((enabled: boolean) => {
|
||||
updateEventLogs(enabled);
|
||||
logStore.logSystem(`Event logs ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}, []);
|
||||
|
||||
const setPromptId = useCallback((id: string) => {
|
||||
updatePromptId(id);
|
||||
logStore.logSystem(`Prompt template updated to ${id}`);
|
||||
}, []);
|
||||
|
||||
const enableLatestBranch = useCallback((enabled: boolean) => {
|
||||
updateLatestBranch(enabled);
|
||||
logStore.logSystem(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(theme: Settings['theme']) => {
|
||||
saveSettings({ theme });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setLanguage = useCallback(
|
||||
(language: string) => {
|
||||
saveSettings({ language });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setNotifications = useCallback(
|
||||
(enabled: boolean) => {
|
||||
saveSettings({ notifications: enabled });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
const setTimezone = useCallback(
|
||||
(timezone: string) => {
|
||||
saveSettings({ timezone });
|
||||
},
|
||||
[saveSettings],
|
||||
);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
debug,
|
||||
enableDebugMode,
|
||||
eventLogs,
|
||||
setEventLogs,
|
||||
promptId,
|
||||
setPromptId,
|
||||
isLatestBranch,
|
||||
enableLatestBranch,
|
||||
setTheme,
|
||||
setLanguage,
|
||||
setNotifications,
|
||||
setTimezone,
|
||||
settings,
|
||||
tabConfiguration,
|
||||
updateTabConfiguration: updateTabConfig,
|
||||
resetTabConfiguration: resetTabConfig,
|
||||
};
|
||||
}
|
||||
93
app/.client/hooks/useShortcuts.ts
Normal file
93
app/.client/hooks/useShortcuts.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect } from 'react';
|
||||
import { type Shortcuts, shortcutsStore } from '~/.client/stores/settings';
|
||||
import { isMac } from '~/.client/utils/os';
|
||||
|
||||
// List of keys that should not trigger shortcuts when typing in input/textarea
|
||||
const INPUT_ELEMENTS = ['input', 'textarea'];
|
||||
|
||||
class ShortcutEventEmitter {
|
||||
#emitter = new EventTarget();
|
||||
|
||||
dispatch(type: keyof Shortcuts) {
|
||||
this.#emitter.dispatchEvent(new Event(type));
|
||||
}
|
||||
|
||||
on(type: keyof Shortcuts, cb: VoidFunction) {
|
||||
this.#emitter.addEventListener(type, cb);
|
||||
|
||||
return () => {
|
||||
this.#emitter.removeEventListener(type, cb);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const shortcutEventEmitter = new ShortcutEventEmitter();
|
||||
|
||||
export function useShortcuts(): void {
|
||||
const shortcuts = useStore(shortcutsStore);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
// Don't trigger shortcuts when typing in input fields
|
||||
if (
|
||||
document.activeElement &&
|
||||
INPUT_ELEMENTS.includes(document.activeElement.tagName.toLowerCase()) &&
|
||||
!event.altKey && // Allow Alt combinations even in input fields
|
||||
!event.metaKey && // Allow Cmd/Win combinations even in input fields
|
||||
!event.ctrlKey // Allow Ctrl combinations even in input fields
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug logging in development only
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Key pressed:', {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
target: event.target,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle shortcuts
|
||||
for (const [name, shortcut] of Object.entries(shortcuts)) {
|
||||
const keyMatches =
|
||||
shortcut.key.toLowerCase() === event.key.toLowerCase() || `Key${shortcut.key.toUpperCase()}` === event.code;
|
||||
|
||||
// Handle ctrlOrMetaKey based on OS
|
||||
const ctrlOrMetaKeyMatches = shortcut.ctrlOrMetaKey
|
||||
? (isMac && event.metaKey) || (!isMac && event.ctrlKey)
|
||||
: true;
|
||||
|
||||
const modifiersMatch =
|
||||
ctrlOrMetaKeyMatches &&
|
||||
(shortcut.ctrlKey === undefined || shortcut.ctrlKey === event.ctrlKey) &&
|
||||
(shortcut.metaKey === undefined || shortcut.metaKey === event.metaKey) &&
|
||||
(shortcut.shiftKey === undefined || shortcut.shiftKey === event.shiftKey) &&
|
||||
(shortcut.altKey === undefined || shortcut.altKey === event.altKey);
|
||||
|
||||
if (keyMatches && modifiersMatch) {
|
||||
// Prevent default browser behavior if specified
|
||||
if (shortcut.isPreventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
shortcutEventEmitter.dispatch(name as keyof Shortcuts);
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcuts]);
|
||||
}
|
||||
177
app/.client/hooks/useSnapScroll.ts
Normal file
177
app/.client/hooks/useSnapScroll.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { throttle } from '~/.client/utils/throttle';
|
||||
|
||||
interface ScrollOptions {
|
||||
duration?: number;
|
||||
easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
|
||||
cubicBezier?: [number, number, number, number];
|
||||
bottomThreshold?: number;
|
||||
throttleTime?: number;
|
||||
}
|
||||
|
||||
export function useSnapScroll(options: ScrollOptions = {}) {
|
||||
const {
|
||||
duration = 800,
|
||||
easing = 'ease-in-out',
|
||||
cubicBezier = [0.42, 0, 0.58, 1],
|
||||
bottomThreshold = 50,
|
||||
throttleTime = 200,
|
||||
} = options;
|
||||
|
||||
const autoScrollRef = useRef(true);
|
||||
const scrollNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const onScrollRef = useRef<() => void>();
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const lastScrollTopRef = useRef<number>(0);
|
||||
const throttledSmoothScrollRef = useRef<(...args: any[]) => void>();
|
||||
|
||||
const smoothScroll = useCallback(
|
||||
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
||||
const startPosition = element.scrollTop;
|
||||
const distance = targetPosition - startPosition;
|
||||
const startTime = performance.now();
|
||||
|
||||
const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1];
|
||||
|
||||
const cubicBezierFunction = (t: number): number => {
|
||||
const [, y1, , y2] = bezierPoints;
|
||||
|
||||
/*
|
||||
* const cx = 3 * x1;
|
||||
* const bx = 3 * (x2 - x1) - cx;
|
||||
* const ax = 1 - cx - bx;
|
||||
*/
|
||||
|
||||
const cy = 3 * y1;
|
||||
const by = 3 * (y2 - y1) - cy;
|
||||
const ay = 1 - cy - by;
|
||||
|
||||
// const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
|
||||
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
|
||||
|
||||
return sampleCurveY(t);
|
||||
};
|
||||
|
||||
const animation = (currentTime: number) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
const easedProgress = cubicBezierFunction(progress);
|
||||
const newPosition = startPosition + distance * easedProgress;
|
||||
|
||||
// Only scroll if auto-scroll is still enabled
|
||||
if (autoScrollRef.current) {
|
||||
element.scrollTop = newPosition;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
if (progress < 1 && autoScrollRef.current) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animation(performance.now());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animation(performance.now());
|
||||
});
|
||||
},
|
||||
[cubicBezier],
|
||||
);
|
||||
|
||||
// 创建节流版本的 smoothScroll 函数
|
||||
useCallback(() => {
|
||||
throttledSmoothScrollRef.current = throttle(
|
||||
(element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
|
||||
smoothScroll(element, targetPosition, duration, easingFunction);
|
||||
},
|
||||
throttleTime,
|
||||
);
|
||||
}, [smoothScroll, throttleTime])();
|
||||
|
||||
const isScrolledToBottom = useCallback(
|
||||
(element: HTMLDivElement): boolean => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
return scrollHeight - scrollTop - clientHeight <= bottomThreshold;
|
||||
},
|
||||
[bottomThreshold],
|
||||
);
|
||||
|
||||
const messageRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoScrollRef.current && scrollNodeRef.current && throttledSmoothScrollRef.current) {
|
||||
const { scrollHeight, clientHeight } = scrollNodeRef.current;
|
||||
const scrollTarget = scrollHeight - clientHeight;
|
||||
throttledSmoothScrollRef.current(scrollNodeRef.current, scrollTarget, duration, easing);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
return;
|
||||
}
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = 0;
|
||||
}
|
||||
},
|
||||
[duration, easing],
|
||||
);
|
||||
|
||||
const scrollRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
onScrollRef.current = () => {
|
||||
const { scrollTop } = node;
|
||||
|
||||
// Detect scroll direction
|
||||
const isScrollingUp = scrollTop < lastScrollTopRef.current;
|
||||
|
||||
// Update auto-scroll based on scroll direction and position
|
||||
if (isScrollingUp) {
|
||||
// Disable auto-scroll when scrolling up
|
||||
autoScrollRef.current = false;
|
||||
} else if (isScrolledToBottom(node)) {
|
||||
// Re-enable auto-scroll when manually scrolled to bottom
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
|
||||
// Store current scroll position for next comparison
|
||||
lastScrollTopRef.current = scrollTop;
|
||||
};
|
||||
|
||||
node.addEventListener('scroll', onScrollRef.current);
|
||||
scrollNodeRef.current = node;
|
||||
} else {
|
||||
if (onScrollRef.current && scrollNodeRef.current) {
|
||||
scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current);
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = 0;
|
||||
}
|
||||
|
||||
scrollNodeRef.current = null;
|
||||
onScrollRef.current = undefined;
|
||||
}
|
||||
},
|
||||
[isScrolledToBottom],
|
||||
);
|
||||
|
||||
return [messageRef, scrollRef] as const;
|
||||
}
|
||||
18
app/.client/hooks/useViewport.ts
Normal file
18
app/.client/hooks/useViewport.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useViewport = (threshold = 1024) => {
|
||||
const [isSmallViewport, setIsSmallViewport] = useState(window.innerWidth < threshold);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsSmallViewport(window.innerWidth < threshold);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
return isSmallViewport;
|
||||
};
|
||||
|
||||
export default useViewport;
|
||||
Reference in New Issue
Block a user