refactor: optimize component rendering with memoization and improve state management
- Added memoization to ChatHistoryPanel, ChatMessageList, and TaskBoard components to prevent unnecessary re-renders. - Refactored HomePage to utilize useMemo for derived state calculations, enhancing performance. - Updated main.tsx to conditionally render React.StrictMode based on the environment. - Improved chat and channel store hooks to allow for selector functions, enhancing flexibility in state selection. - Enhanced streaming message handling in chat store to manage pending deltas more effectively. - Refactored LoginPage to include animated decorations for improved user experience. - Implemented lazy loading for routes in the router to optimize initial load time.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import type { ChatHistoryBucket } from './types';
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -31,7 +31,7 @@ function cx(...classes: Array<string | false | null | undefined>): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function ChatHistoryPanel({
|
||||
function ChatHistoryPanel({
|
||||
buckets,
|
||||
selectedConversationId,
|
||||
loading,
|
||||
@@ -50,12 +50,17 @@ export default function ChatHistoryPanel({
|
||||
useEffect(() => {
|
||||
setCollapsedBuckets((current) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
const currentKeys = Object.keys(current);
|
||||
let changed = currentKeys.length !== buckets.length;
|
||||
|
||||
for (const bucket of buckets) {
|
||||
next[bucket.key] = current[bucket.key] ?? false;
|
||||
if (current[bucket.key] !== next[bucket.key]) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [buckets]);
|
||||
|
||||
@@ -299,3 +304,5 @@ export default function ChatHistoryPanel({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatHistoryPanel);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import type { ChatMessageItem } from './types';
|
||||
|
||||
type ChatMessageListProps = {
|
||||
@@ -6,7 +6,7 @@ type ChatMessageListProps = {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function ChatMessageList({ messages, loading }: ChatMessageListProps) {
|
||||
function ChatMessageList({ messages, loading }: ChatMessageListProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,3 +90,5 @@ export default function ChatMessageList({ messages, loading }: ChatMessageListPr
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatMessageList);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import type { TaskItem } from './types';
|
||||
|
||||
type TaskBoardProps = {
|
||||
@@ -11,7 +12,7 @@ type TaskBoardProps = {
|
||||
currentTime?: string;
|
||||
};
|
||||
|
||||
export default function TaskBoard({
|
||||
function TaskBoard({
|
||||
activeTab,
|
||||
pendingItems,
|
||||
completedItems,
|
||||
@@ -112,3 +113,11 @@ export default function TaskBoard({
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TaskBoard, (prevProps, nextProps) => (
|
||||
prevProps.activeTab === nextProps.activeTab
|
||||
&& prevProps.pendingItems === nextProps.pendingItems
|
||||
&& prevProps.completedItems === nextProps.completedItems
|
||||
&& prevProps.currentDateLabel === nextProps.currentDateLabel
|
||||
&& prevProps.currentTime === nextProps.currentTime
|
||||
));
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -9,8 +9,14 @@ if (!container) {
|
||||
throw new Error('Missing #app container for React entry.');
|
||||
}
|
||||
|
||||
const app = <App />;
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
import.meta.env.DEV
|
||||
? app
|
||||
: (
|
||||
<React.StrictMode>
|
||||
{app}
|
||||
</React.StrictMode>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RawMessage } from '../../shared/chat-model';
|
||||
import { extractText, isInternalMessage } from '../../shared/chat-model';
|
||||
import { taskCenterList, type TaskCenterItem } from '../../constants/taskCenterList';
|
||||
@@ -123,15 +123,19 @@ function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null
|
||||
if (message.role === 'user' || message.role === 'assistant') return true;
|
||||
return Boolean(extractText(message).trim());
|
||||
})
|
||||
.map((message): ChatMessageItem => ({
|
||||
id: message.id || `msg-${message.timestamp || Math.random()}`,
|
||||
role: message.role === 'user' ? 'user' : 'assistant',
|
||||
name: message.role === 'user' ? '你' : 'YINIAN',
|
||||
time: getMessageTime(message.timestamp),
|
||||
content: extractText(message),
|
||||
attachments: message._attachedFiles,
|
||||
isError: Boolean(message.isError),
|
||||
}));
|
||||
.map((message): ChatMessageItem => {
|
||||
const text = extractText(message);
|
||||
|
||||
return {
|
||||
id: message.id || `${message.role}-${message.timestamp || 0}-${text.slice(0, 32)}`,
|
||||
role: message.role === 'user' ? 'user' : 'assistant',
|
||||
name: message.role === 'user' ? '你' : 'YINIAN',
|
||||
time: getMessageTime(message.timestamp),
|
||||
content: text,
|
||||
attachments: message._attachedFiles,
|
||||
isError: Boolean(message.isError),
|
||||
};
|
||||
});
|
||||
|
||||
if (streamingMessage && extractText(streamingMessage).trim()) {
|
||||
visibleMessages.push({
|
||||
@@ -148,74 +152,46 @@ function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null
|
||||
return visibleMessages;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const modelsState = useModelsStore();
|
||||
const chat = useChatStore();
|
||||
const taskState = useTaskStore();
|
||||
const channelState = useChannelStore();
|
||||
function handleNewConversation(): void {
|
||||
const currentAgentId = chatStore.getState().currentAgentId;
|
||||
const defaultAgentId = modelsStore.getState().defaultAgentId;
|
||||
const targetAgentId = currentAgentId && currentAgentId !== 'local' ? currentAgentId : defaultAgentId;
|
||||
void chatStore.newSession(targetAgentId || undefined);
|
||||
}
|
||||
|
||||
function handleSelectConversation(conversationId: string): void {
|
||||
chatStore.switchSession(conversationId);
|
||||
}
|
||||
|
||||
function handleRenameConversation(conversationId: string): void {
|
||||
const chat = chatStore.getState();
|
||||
const currentLabel = chat.sessionLabels[conversationId]
|
||||
|| chat.sessions.find((session) => session.key === conversationId)?.displayName
|
||||
|| '';
|
||||
const nextLabel = window.prompt('重命名对话', currentLabel);
|
||||
if (nextLabel) {
|
||||
chatStore.renameSession(conversationId, nextLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteConversation(conversationId: string): void {
|
||||
const confirmed = window.confirm('确定删除该会话吗?删除后将无法恢复。');
|
||||
if (confirmed) {
|
||||
void chatStore.deleteSession(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshConversationData(): void {
|
||||
void modelsStore.load();
|
||||
void chatStore.loadSessions();
|
||||
void chatStore.loadHistory();
|
||||
}
|
||||
|
||||
function HomeChatComposerSection() {
|
||||
const error = useChatStore((state) => state.error);
|
||||
const isSending = useChatStore((state) => state.sending);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [attachments, setAttachments] = useState<StagedAttachment[]>([]);
|
||||
const [activeTaskTab, setActiveTaskTab] = useState<TaskTabValue>('pending');
|
||||
const [taskCenterNotice, setTaskCenterNotice] = useState<string | null>(null);
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void modelsStore.init();
|
||||
void chatStore.init();
|
||||
void taskStore.init();
|
||||
void channelStore.init();
|
||||
}, []);
|
||||
|
||||
const currentMs = Date.now();
|
||||
const historyBuckets: ChatHistoryBucket[] = HISTORY_BUCKET_META.map((bucket) => ({
|
||||
...bucket,
|
||||
sessions: [],
|
||||
}));
|
||||
const bucketMap = new Map(historyBuckets.map((bucket) => [bucket.key, bucket]));
|
||||
|
||||
for (const session of [...chat.sessions].sort((left, right) => {
|
||||
const leftTime = chat.sessionLastActivity[left.key] || left.updatedAt || 0;
|
||||
const rightTime = chat.sessionLastActivity[right.key] || right.updatedAt || 0;
|
||||
return rightTime - leftTime;
|
||||
})) {
|
||||
const activityMs = chat.sessionLastActivity[session.key] || session.updatedAt || 0;
|
||||
const bucketKey = getHistoryBucket(activityMs, currentMs);
|
||||
const targetBucket = bucketMap.get(bucketKey);
|
||||
if (!targetBucket) continue;
|
||||
|
||||
targetBucket.sessions.push({
|
||||
conversationId: session.key,
|
||||
title: chat.sessionLabels[session.key] || session.displayName || session.key,
|
||||
updatedAt: formatHistoryTime(activityMs, currentMs),
|
||||
});
|
||||
}
|
||||
|
||||
const pendingTasks = getPendingTasks(taskState.tasks);
|
||||
const completedTasks = getCompletedTasks(taskState.tasks);
|
||||
const pendingTaskItems: TaskItem[] = pendingTasks.flatMap((task) => task.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
removeTaskId: task.id,
|
||||
title: subTask.name,
|
||||
description: subTask.message || task.title,
|
||||
status: subTask.status,
|
||||
meta: task.title,
|
||||
})));
|
||||
const completedTaskItems: TaskItem[] = completedTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: `${task.operation === 'open' ? '开启' : '关闭'} ${getTaskRoomTypeLabel(task)}`,
|
||||
status: task.status,
|
||||
meta: `${task.dateRange[0]} ~ ${task.dateRange[1]}`,
|
||||
}));
|
||||
const currentTaskSource = activeTaskTab === 'pending' ? pendingTasks : completedTasks;
|
||||
const latestTask = currentTaskSource[0];
|
||||
|
||||
const visibleMessages = mapMessages(chat.messages, chat.streamingMessage);
|
||||
const selectedModelId = modelsState.models.some((model) => model.id === chat.currentAgentId)
|
||||
? chat.currentAgentId
|
||||
: modelsState.defaultAgentId;
|
||||
const currentModel = modelsState.models.find((model) => model.id === selectedModelId) || null;
|
||||
|
||||
async function handleSendMessage(): Promise<void> {
|
||||
const sent = await chatStore.sendMessage(inputMessage, attachments);
|
||||
@@ -230,17 +206,140 @@ export default function HomePage() {
|
||||
setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatComposer
|
||||
attachments={attachments}
|
||||
error={error}
|
||||
isSending={isSending}
|
||||
value={inputMessage}
|
||||
onAttach={handleAttach}
|
||||
onChange={setInputMessage}
|
||||
onDismissError={chatStore.clearError}
|
||||
onRemoveAttachment={(index) => {
|
||||
setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
|
||||
}}
|
||||
onSend={() => {
|
||||
void handleSendMessage();
|
||||
}}
|
||||
onStop={() => {
|
||||
void chatStore.abortRun();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const models = useModelsStore((state) => state.models);
|
||||
const modelsLoading = useModelsStore((state) => state.loading);
|
||||
const defaultAgentId = useModelsStore((state) => state.defaultAgentId);
|
||||
const chatInitialized = useChatStore((state) => state.initialized);
|
||||
const chatMessages = useChatStore((state) => state.messages);
|
||||
const chatLoading = useChatStore((state) => state.loading);
|
||||
const chatStreamingMessage = useChatStore((state) => state.streamingMessage);
|
||||
const chatSessions = useChatStore((state) => state.sessions);
|
||||
const chatCurrentSessionKey = useChatStore((state) => state.currentSessionKey);
|
||||
const chatCurrentAgentId = useChatStore((state) => state.currentAgentId);
|
||||
const chatSessionLabels = useChatStore((state) => state.sessionLabels);
|
||||
const chatSessionLastActivity = useChatStore((state) => state.sessionLastActivity);
|
||||
const chatGatewayStatus = useChatStore((state) => state.gatewayStatus);
|
||||
const tasks = useTaskStore((state) => state.tasks);
|
||||
const availableChannels = useChannelStore((state) => state.availableChannels);
|
||||
const channelLoading = useChannelStore((state) => state.loading);
|
||||
const selectedChannels = useChannelStore((state) => state.selectedChannels);
|
||||
const [activeTaskTab, setActiveTaskTab] = useState<TaskTabValue>('pending');
|
||||
const [taskCenterNotice, setTaskCenterNotice] = useState<string | null>(null);
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void modelsStore.init();
|
||||
void chatStore.init();
|
||||
void taskStore.init();
|
||||
void channelStore.init();
|
||||
}, []);
|
||||
|
||||
const historyBuckets = useMemo<ChatHistoryBucket[]>(() => {
|
||||
const currentMs = Date.now();
|
||||
const buckets: ChatHistoryBucket[] = HISTORY_BUCKET_META.map((bucket) => ({
|
||||
...bucket,
|
||||
sessions: [],
|
||||
}));
|
||||
const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket]));
|
||||
|
||||
for (const session of [...chatSessions].sort((left, right) => {
|
||||
const leftTime = chatSessionLastActivity[left.key] || left.updatedAt || 0;
|
||||
const rightTime = chatSessionLastActivity[right.key] || right.updatedAt || 0;
|
||||
return rightTime - leftTime;
|
||||
})) {
|
||||
const activityMs = chatSessionLastActivity[session.key] || session.updatedAt || 0;
|
||||
const bucketKey = getHistoryBucket(activityMs, currentMs);
|
||||
const targetBucket = bucketMap.get(bucketKey);
|
||||
if (!targetBucket) continue;
|
||||
|
||||
targetBucket.sessions.push({
|
||||
conversationId: session.key,
|
||||
title: chatSessionLabels[session.key] || session.displayName || session.key,
|
||||
updatedAt: formatHistoryTime(activityMs, currentMs),
|
||||
});
|
||||
}
|
||||
|
||||
return buckets;
|
||||
}, [chatSessionLabels, chatSessionLastActivity, chatSessions]);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
() => mapMessages(chatMessages, chatStreamingMessage),
|
||||
[chatMessages, chatStreamingMessage],
|
||||
);
|
||||
|
||||
const pendingTasks = useMemo(() => getPendingTasks(tasks), [tasks]);
|
||||
const completedTasks = useMemo(() => getCompletedTasks(tasks), [tasks]);
|
||||
|
||||
const pendingTaskItems = useMemo<TaskItem[]>(() => (
|
||||
pendingTasks.flatMap((task) => task.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
removeTaskId: task.id,
|
||||
title: subTask.name,
|
||||
description: subTask.message || task.title,
|
||||
status: subTask.status,
|
||||
meta: task.title,
|
||||
})))
|
||||
), [pendingTasks]);
|
||||
|
||||
const completedTaskItems = useMemo<TaskItem[]>(() => (
|
||||
completedTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: `${task.operation === 'open' ? '开启' : '关闭'} ${getTaskRoomTypeLabel(task)}`,
|
||||
status: task.status,
|
||||
meta: `${task.dateRange[0]} ~ ${task.dateRange[1]}`,
|
||||
}))
|
||||
), [completedTasks]);
|
||||
|
||||
const currentTaskSource = activeTaskTab === 'pending' ? pendingTasks : completedTasks;
|
||||
const latestTask = currentTaskSource[0];
|
||||
|
||||
const selectedModelId = useMemo(() => (
|
||||
models.some((model) => model.id === chatCurrentAgentId)
|
||||
? chatCurrentAgentId
|
||||
: defaultAgentId
|
||||
), [chatCurrentAgentId, defaultAgentId, models]);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => models.find((model) => model.id === selectedModelId) || null,
|
||||
[models, selectedModelId],
|
||||
);
|
||||
|
||||
async function handleTaskCenterItem(item: TaskCenterItem): Promise<void> {
|
||||
setTaskCenterNotice(null);
|
||||
|
||||
if (item.type === 'channel') {
|
||||
if (!channelState.selectedChannels.length) {
|
||||
if (!selectedChannels.length) {
|
||||
setTaskCenterNotice('请先在当前项目里配置“已选渠道”,再执行一键打开。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invokeIpc(IPC_EVENTS.OPEN_CHANNEL, channelState.selectedChannels);
|
||||
await invokeIpc(IPC_EVENTS.OPEN_CHANNEL, selectedChannels);
|
||||
setTaskCenterNotice('已触发“打开渠道”操作。');
|
||||
} catch (error) {
|
||||
setTaskCenterNotice(error instanceof Error ? error.message : String(error));
|
||||
@@ -251,7 +350,7 @@ export default function HomePage() {
|
||||
setTaskDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveChannels(items: typeof channelState.selectedChannels): Promise<void> {
|
||||
async function handleSaveChannels(items: typeof selectedChannels): Promise<void> {
|
||||
await channelStore.saveSelectedChannels(items);
|
||||
setTaskCenterNotice('已更新“打开渠道”配置。');
|
||||
setAddChannelDialogOpen(false);
|
||||
@@ -281,33 +380,16 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<section className="h-full w-full min-h-0">
|
||||
<div className="flex h-full w-full min-h-0 flex-col gap-2 md:flex-row">
|
||||
<div className="flex h-full w-full min-h-0 flex-col gap-2 md:flex-row">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] dark:bg-[#1b1b1d] md:flex-row">
|
||||
<ChatHistoryPanel
|
||||
buckets={historyBuckets}
|
||||
loading={!chat.initialized}
|
||||
selectedConversationId={chat.currentSessionKey}
|
||||
onNewChat={() => {
|
||||
void chatStore.newSession(selectedModelId || undefined);
|
||||
}}
|
||||
onSelectConversation={(conversationId) => {
|
||||
chatStore.switchSession(conversationId);
|
||||
}}
|
||||
onRenameConversation={(conversationId) => {
|
||||
const currentLabel = chat.sessionLabels[conversationId]
|
||||
|| chat.sessions.find((session) => session.key === conversationId)?.displayName
|
||||
|| '';
|
||||
const nextLabel = window.prompt('重命名对话', currentLabel);
|
||||
if (nextLabel) {
|
||||
chatStore.renameSession(conversationId, nextLabel);
|
||||
}
|
||||
}}
|
||||
onDeleteConversation={(conversationId) => {
|
||||
const confirmed = window.confirm('确定删除该会话吗?删除后将无法恢复。');
|
||||
if (confirmed) {
|
||||
void chatStore.deleteSession(conversationId);
|
||||
}
|
||||
}}
|
||||
loading={!chatInitialized}
|
||||
selectedConversationId={chatCurrentSessionKey}
|
||||
onNewChat={handleNewConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -315,7 +397,7 @@ export default function HomePage() {
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#171717] dark:text-gray-100">智能对话</h2>
|
||||
<div className="mt-1 text-xs text-[#99A0AE] dark:text-gray-500">
|
||||
网关状态:{chat.gatewayStatus === 'connected' ? '已连接' : chat.gatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
|
||||
网关状态:{chatGatewayStatus === 'connected' ? '已连接' : chatGatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
|
||||
{currentModel ? ` · 当前模型:${currentModel.name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,13 +406,13 @@ export default function HomePage() {
|
||||
<span>模型</span>
|
||||
<select
|
||||
className="rounded-full border border-[#E5E8EE] bg-white px-3 py-1.5 text-xs text-[#525866] outline-none transition-colors hover:border-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300"
|
||||
disabled={modelsState.loading || modelsState.models.length === 0}
|
||||
disabled={modelsLoading || models.length === 0}
|
||||
value={selectedModelId}
|
||||
onChange={(event) => {
|
||||
chatStore.selectAgent(event.target.value);
|
||||
}}
|
||||
>
|
||||
{modelsState.models.map((model) => (
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
@@ -340,11 +422,7 @@ export default function HomePage() {
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
onClick={() => {
|
||||
void modelsStore.load();
|
||||
void chatStore.loadSessions();
|
||||
void chatStore.loadHistory();
|
||||
}}
|
||||
onClick={handleRefreshConversationData}
|
||||
>
|
||||
刷新会话
|
||||
</button>
|
||||
@@ -352,25 +430,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatMessageList loading={chat.loading} messages={visibleMessages} />
|
||||
<ChatComposer
|
||||
attachments={attachments}
|
||||
error={chat.error}
|
||||
isSending={chat.sending}
|
||||
value={inputMessage}
|
||||
onAttach={handleAttach}
|
||||
onChange={setInputMessage}
|
||||
onDismissError={() => chatStore.clearError()}
|
||||
onRemoveAttachment={(index) => {
|
||||
setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
|
||||
}}
|
||||
onSend={() => {
|
||||
void handleSendMessage();
|
||||
}}
|
||||
onStop={() => {
|
||||
void chatStore.abortRun();
|
||||
}}
|
||||
/>
|
||||
<ChatMessageList loading={chatLoading} messages={visibleMessages} />
|
||||
<HomeChatComposerSection />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#edf2f7] dark:border-[#2a2a2d]">
|
||||
@@ -466,9 +527,9 @@ export default function HomePage() {
|
||||
|
||||
<AddChannelDialog
|
||||
open={addChannelDialogOpen}
|
||||
loading={channelState.loading}
|
||||
availableChannels={channelState.availableChannels}
|
||||
initialSelected={channelState.selectedChannels}
|
||||
loading={channelLoading}
|
||||
availableChannels={availableChannels}
|
||||
initialSelected={selectedChannels}
|
||||
onClose={() => setAddChannelDialogOpen(false)}
|
||||
onConfirm={handleSaveChannels}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function LoginPage() {
|
||||
const [form, setForm] = useState<LoginFormValues>(INITIAL_FORM);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showDecorations, setShowDecorations] = useState(false);
|
||||
|
||||
const captchaUrl = useMemo(
|
||||
() => (form.randomStr ? getCaptchaImageUrl(form.randomStr) : ''),
|
||||
@@ -50,7 +51,14 @@ export default function LoginPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setForm((current) => ({ ...current, ...createCaptchaState() }));
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
setShowDecorations(true);
|
||||
setForm((current) => ({ ...current, ...createCaptchaState() }));
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function refreshCaptcha(clearSubmitError = true): void {
|
||||
@@ -123,20 +131,25 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-screen flex-col"
|
||||
style={{
|
||||
backgroundImage: `url(${loginBackground})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: '0 0',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
className="relative flex h-screen flex-col overflow-hidden bg-[linear-gradient(135deg,#eef5ff_0%,#f8fbff_42%,#ffffff_100%)] dark:bg-[#111214]"
|
||||
>
|
||||
{showDecorations ? (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
src={loginBackground}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TitleBar variant="light" />
|
||||
|
||||
<main
|
||||
className={['box-border flex flex-auto pl-2 pr-2 pb-2', platform !== 'linux' ? 'pt-2' : 'pt-11'].join(' ')}
|
||||
className={['relative z-10 box-border flex flex-auto pl-2 pr-2 pb-2', platform !== 'linux' ? 'pt-2' : 'pt-11'].join(' ')}
|
||||
>
|
||||
<div className="box-border w-[836px] rounded-2xl border border-black/5 bg-white p-8 shadow-[0_18px_50px_rgba(15,23,42,0.12)] dark:border-[#2a2a2d] dark:bg-[#1b1b1d]">
|
||||
<div className="box-border w-[836px] rounded-2xl border border-black/5 bg-white/95 p-8 shadow-[0_18px_50px_rgba(15,23,42,0.12)] backdrop-blur-sm dark:border-[#2a2a2d] dark:bg-[#1b1b1d]/95">
|
||||
<div className="flex items-center">
|
||||
<img className="h-12 w-12" src={blueLogo} alt="zn-ai" />
|
||||
</div>
|
||||
@@ -241,7 +254,17 @@ export default function LoginPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<img className="ml-2 w-[540px] max-w-[48vw] self-center object-contain" src={loginIllustration} alt="" />
|
||||
{showDecorations ? (
|
||||
<img
|
||||
aria-hidden="true"
|
||||
alt=""
|
||||
className="ml-2 w-[540px] max-w-[48vw] self-center object-contain"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
loading="eager"
|
||||
src={loginIllustration}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Suspense, lazy, useEffect, type ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import HomePage from '../pages/Home';
|
||||
import LoginPage from '../pages/Login';
|
||||
import ModelsPage from '../pages/Models';
|
||||
import SkillsPage from '../pages/Skills';
|
||||
import CronPage from '../pages/Cron';
|
||||
import ScriptsPage from '../pages/Scripts';
|
||||
import SettingPage from '../pages/Setting';
|
||||
import KnowledgePage from '../pages/Knowledge';
|
||||
import { DEFAULT_PATH } from './routes';
|
||||
import { RedirectAuthenticated, RequireAuth, isAuthenticated } from './auth';
|
||||
import { onAuthLogout } from './auth-session';
|
||||
|
||||
const HomePage = lazy(() => import('../pages/Home'));
|
||||
const ModelsPage = lazy(() => import('../pages/Models'));
|
||||
const SkillsPage = lazy(() => import('../pages/Skills'));
|
||||
const CronPage = lazy(() => import('../pages/Cron'));
|
||||
const ScriptsPage = lazy(() => import('../pages/Scripts'));
|
||||
const SettingPage = lazy(() => import('../pages/Setting'));
|
||||
const KnowledgePage = lazy(() => import('../pages/Knowledge'));
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center bg-[#f6f9fc] text-sm text-[#99A0AE] dark:bg-[#0f0f10] dark:text-gray-500">
|
||||
页面加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLazyPage(element: ReactNode) {
|
||||
return (
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
{element}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthLogoutRedirector() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -44,14 +61,14 @@ export function AppRouter() {
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/models" element={<ModelsPage />} />
|
||||
<Route path="/home" element={renderLazyPage(<HomePage />)} />
|
||||
<Route path="/models" element={renderLazyPage(<ModelsPage />)} />
|
||||
<Route path="/agents" element={<Navigate to="/models" replace />} />
|
||||
<Route path="/skills" element={<SkillsPage />} />
|
||||
<Route path="/cron" element={<CronPage />} />
|
||||
<Route path="/scripts" element={<ScriptsPage />} />
|
||||
<Route path="/setting" element={<SettingPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/skills" element={renderLazyPage(<SkillsPage />)} />
|
||||
<Route path="/cron" element={renderLazyPage(<CronPage />)} />
|
||||
<Route path="/scripts" element={renderLazyPage(<ScriptsPage />)} />
|
||||
<Route path="/setting" element={renderLazyPage(<SettingPage />)} />
|
||||
<Route path="/knowledge" element={renderLazyPage(<KnowledgePage />)} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ export const channelStore = {
|
||||
removeSelectedChannel,
|
||||
};
|
||||
|
||||
export function useChannelStore(): ChannelStoreState {
|
||||
return useSyncExternalStore(channelStore.subscribe, channelStore.getSnapshot, channelStore.getSnapshot);
|
||||
export function useChannelStore<T = ChannelStoreState>(selector?: (state: ChannelStoreState) => T): T {
|
||||
const select = selector ?? ((current: ChannelStoreState) => current as unknown as T);
|
||||
return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ const listeners = new Set<() => void>();
|
||||
const historyLoadInFlight = new Map<string, Promise<void>>();
|
||||
const lastHistoryLoadAtBySession = new Map<string, number>();
|
||||
const chatEventDedupe = new Map<string, number>();
|
||||
let pendingStreamingDelta = '';
|
||||
let pendingStreamingRunId: string | null = null;
|
||||
let streamingFlushHandle: number | null = null;
|
||||
|
||||
let gatewaySubscribed = false;
|
||||
let loadSessionsInFlight: Promise<void> | null = null;
|
||||
@@ -98,6 +101,79 @@ function patchState(patch: Partial<ChatStoreState>): ChatStoreState {
|
||||
return state;
|
||||
}
|
||||
|
||||
function requestFrame(callback: () => void): number {
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
return window.requestAnimationFrame(() => callback());
|
||||
}
|
||||
|
||||
return window.setTimeout(callback, 16);
|
||||
}
|
||||
|
||||
function cancelFrame(handle: number): void {
|
||||
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
|
||||
function applyPendingStreamingDelta(): void {
|
||||
if (!pendingStreamingDelta) return;
|
||||
|
||||
const previousContent = state.streamingMessage ? extractText(state.streamingMessage) : '';
|
||||
const nextContent = previousContent + pendingStreamingDelta;
|
||||
const nextRunId = pendingStreamingRunId;
|
||||
|
||||
pendingStreamingDelta = '';
|
||||
pendingStreamingRunId = null;
|
||||
|
||||
patchState({
|
||||
sending: true,
|
||||
error: null,
|
||||
activeRunId: nextRunId ?? state.activeRunId,
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: nextContent,
|
||||
timestamp: Date.now(),
|
||||
id: state.streamingMessage?.id || `stream-${nextRunId || Date.now()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function flushPendingStreamingDelta(): void {
|
||||
if (streamingFlushHandle !== null) {
|
||||
cancelFrame(streamingFlushHandle);
|
||||
streamingFlushHandle = null;
|
||||
}
|
||||
|
||||
applyPendingStreamingDelta();
|
||||
}
|
||||
|
||||
function resetPendingStreamingDelta(): void {
|
||||
if (streamingFlushHandle !== null) {
|
||||
cancelFrame(streamingFlushHandle);
|
||||
streamingFlushHandle = null;
|
||||
}
|
||||
|
||||
pendingStreamingDelta = '';
|
||||
pendingStreamingRunId = null;
|
||||
}
|
||||
|
||||
function queueStreamingDelta(delta: string, runId?: string): void {
|
||||
if (!delta) return;
|
||||
|
||||
pendingStreamingDelta += delta;
|
||||
pendingStreamingRunId = runId ?? pendingStreamingRunId;
|
||||
|
||||
if (streamingFlushHandle !== null) return;
|
||||
|
||||
streamingFlushHandle = requestFrame(() => {
|
||||
streamingFlushHandle = null;
|
||||
applyPendingStreamingDelta();
|
||||
});
|
||||
}
|
||||
|
||||
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
const parsed = parseSessionKey(normalizeAgentSessionKey(sessionKey));
|
||||
if (parsed.isAgentSession) return parsed.agentId;
|
||||
@@ -527,6 +603,8 @@ async function loadHistory(sessionKey = state.currentSessionKey, quiet = false):
|
||||
}
|
||||
|
||||
async function newSession(agentId = state.currentAgentId): Promise<void> {
|
||||
resetPendingStreamingDelta();
|
||||
|
||||
const leavingEmpty =
|
||||
!state.currentSessionKey.endsWith(':main') &&
|
||||
state.messages.length === 0 &&
|
||||
@@ -560,6 +638,7 @@ async function newSession(agentId = state.currentAgentId): Promise<void> {
|
||||
|
||||
function switchSession(sessionKey: string): void {
|
||||
if (sessionKey === state.currentSessionKey) return;
|
||||
resetPendingStreamingDelta();
|
||||
patchState(buildSessionSwitchPatch(state, sessionKey));
|
||||
void loadHistory(sessionKey);
|
||||
}
|
||||
@@ -575,6 +654,10 @@ function selectAgent(agentId: string): void {
|
||||
}
|
||||
|
||||
async function deleteSession(sessionKey: string): Promise<void> {
|
||||
if (sessionKey === state.currentSessionKey) {
|
||||
resetPendingStreamingDelta();
|
||||
}
|
||||
|
||||
try {
|
||||
await gatewayRpc('session.delete', { sessionKey });
|
||||
} catch {
|
||||
@@ -724,6 +807,7 @@ async function sendMessage(text: string, attachments: StagedAttachment[] = []):
|
||||
|
||||
async function abortRun(): Promise<void> {
|
||||
const sessionKey = state.currentSessionKey;
|
||||
resetPendingStreamingDelta();
|
||||
|
||||
patchState({
|
||||
sending: false,
|
||||
@@ -749,21 +833,12 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
|
||||
|
||||
switch (event.type) {
|
||||
case 'chat:delta': {
|
||||
const previousContent = state.streamingMessage ? extractText(state.streamingMessage) : '';
|
||||
patchState({
|
||||
sending: true,
|
||||
error: null,
|
||||
activeRunId: typeof event.runId === 'string' ? event.runId : state.activeRunId,
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: previousContent + event.delta,
|
||||
timestamp: Date.now(),
|
||||
id: state.streamingMessage?.id || `stream-${event.runId || Date.now()}`,
|
||||
},
|
||||
});
|
||||
queueStreamingDelta(event.delta, typeof event.runId === 'string' ? event.runId : undefined);
|
||||
break;
|
||||
}
|
||||
case 'chat:final': {
|
||||
flushPendingStreamingDelta();
|
||||
|
||||
const composedMessage = state.streamingMessage && typeof event.message.content === 'string'
|
||||
? {
|
||||
...event.message,
|
||||
@@ -806,6 +881,8 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'chat:error': {
|
||||
flushPendingStreamingDelta();
|
||||
|
||||
if (state.streamingMessage && !state.messages.some((message) => message.id === state.streamingMessage?.id)) {
|
||||
patchState({
|
||||
messages: [
|
||||
@@ -827,9 +904,11 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
resetPendingStreamingDelta();
|
||||
break;
|
||||
}
|
||||
case 'chat:aborted': {
|
||||
resetPendingStreamingDelta();
|
||||
patchState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
@@ -902,6 +981,7 @@ export const chatStore = {
|
||||
stageAttachmentFiles,
|
||||
};
|
||||
|
||||
export function useChatStore(): ChatStoreState {
|
||||
return useSyncExternalStore(chatStore.subscribe, chatStore.getSnapshot, chatStore.getSnapshot);
|
||||
export function useChatStore<T = ChatStoreState>(selector?: (state: ChatStoreState) => T): T {
|
||||
const select = selector ?? ((current: ChatStoreState) => current as unknown as T);
|
||||
return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export const modelsStore = {
|
||||
resolveProviderAccountId,
|
||||
};
|
||||
|
||||
export function useModelsStore(): ModelsStoreState {
|
||||
return useSyncExternalStore(modelsStore.subscribe, modelsStore.getSnapshot, modelsStore.getSnapshot);
|
||||
export function useModelsStore<T = ModelsStoreState>(selector?: (state: ModelsStoreState) => T): T {
|
||||
const select = selector ?? ((current: ModelsStoreState) => current as unknown as T);
|
||||
return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
|
||||
}
|
||||
|
||||
@@ -354,8 +354,9 @@ export const taskStore = {
|
||||
removeTask,
|
||||
};
|
||||
|
||||
export function useTaskStore(): TaskStoreState {
|
||||
return useSyncExternalStore(taskStore.subscribe, taskStore.getSnapshot, taskStore.getSnapshot);
|
||||
export function useTaskStore<T = TaskStoreState>(selector?: (state: TaskStoreState) => T): T {
|
||||
const select = selector ?? ((current: TaskStoreState) => current as unknown as T);
|
||||
return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
|
||||
}
|
||||
|
||||
export function getPendingTasks(tasks = state.tasks): Task[] {
|
||||
|
||||
Reference in New Issue
Block a user