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:
duanshuwen
2026-04-18 11:05:49 +08:00
parent 85d92b937f
commit dfa4388087
12 changed files with 533 additions and 189 deletions

View File

@@ -1 +1,137 @@
"use strict";const r=require("electron");var i=(e=>(e.EXTERNAL_OPEN="external-open",e.APP_SET_FRAMELESS="app:set-frameless",e.APP_LOAD_PAGE="app:load-page",e.TAB_CREATE="tab:create",e.TAB_LIST="tab:list",e.TAB_NAVIGATE="tab:navigate",e.TAB_RELOAD="tab:reload",e.TAB_BACK="tab:back",e.TAB_FORWARD="tab:forward",e.TAB_SWITCH="tab:switch",e.TAB_CLOSE="tab:close",e.LOG_TO_MAIN="log-to-main",e.READ_FILE="read-file",e.INVOKE="ipc:invoke",e.INVOKE_ASYNC="ipc:invokeAsync",e.APP_MINIMIZE="app:minimize",e.APP_MAXIMIZE="app:maximize",e.APP_QUIT="app:quit",e.FILE_READ="file:read",e.FILE_WRITE="file:write",e.GET_WINDOW_ID="get-window-id",e.CUSTOM_EVENT="custom:event",e.TIME_UPDATE="time:update",e.RENDERER_IS_READY="renderer-ready",e.SHOW_CONTEXT_MENU="show-context-menu",e.START_A_DIALOGUE="start-a-dialogue",e.OPEN_WINDOW="open-window",e.LOG_DEBUG="log-debug",e.LOG_INFO="log-info",e.LOG_WARN="log-warn",e.LOG_ERROR="log-error",e.CONFIG_UPDATED="config-updated",e.SET_CONFIG="set-config",e.GET_CONFIG="get-config",e.UPDATE_CONFIG="update-config",e.SET_THEME_MODE="set-theme-mode",e.GET_THEME_MODE="get-theme-mode",e.IS_DARK_THEME="is-dark-theme",e.THEME_MODE_UPDATED="theme-mode-updated",e.EXECUTE_SCRIPT="execute-script",e.TASK_PROGRESS="task:progress",e.TASK_STARTED="task:started",e.TASK_COMPLETED="task:completed",e.OPEN_CHANNEL="open-channel",e.SCRIPT_LIST="script:list",e.SCRIPT_GET="script:get",e.SCRIPT_SAVE="script:save",e.SCRIPT_DELETE="script:delete",e.SCRIPT_TOGGLE="script:toggle",e.SCRIPT_RUN="script:run",e.SCRIPT_RECORD_START="script:record-start",e.SCRIPT_RECORD_STOP="script:record-stop",e.SCRIPT_CODEGEN="script:codegen",e.GATEWAY_RPC="gateway:rpc",e.GATEWAY_EVENT="gateway:event",e.UPDATE_CHECK="update:check",e.UPDATE_DOWNLOAD="update:download",e.UPDATE_INSTALL="update:install",e.UPDATE_VERSION="update:version",e.UPDATE_STATUS_CHANGED="update:status-changed",e))(i||{});const d={versions:process.versions,external:{open:e=>r.ipcRenderer.invoke("external-open",e)},platform:process.platform,windowMinimize:()=>r.ipcRenderer.invoke("window:minimize"),windowMaximize:()=>r.ipcRenderer.invoke("window:maximize"),windowClose:()=>r.ipcRenderer.invoke("window:close"),windowIsMaximized:()=>r.ipcRenderer.invoke("window:isMaximized"),viewIsReady:()=>r.ipcRenderer.send(i.RENDERER_IS_READY),app:{setFrameless:e=>r.ipcRenderer.invoke(i.APP_SET_FRAMELESS,e),loadPage:e=>r.ipcRenderer.invoke(i.APP_LOAD_PAGE,e)},readFile:e=>r.ipcRenderer.invoke(i.READ_FILE,e),invoke:(e,...n)=>r.ipcRenderer.invoke(e,...n),invokeAsync:(e,...n)=>r.ipcRenderer.invoke(e,...n),on:(e,n)=>{const t=(o,...R)=>n(...R);return r.ipcRenderer.on(e,t),()=>r.ipcRenderer.removeListener(e,t)},send:(e,...n)=>r.ipcRenderer.send(e,...n),getCurrentWindowId:()=>r.ipcRenderer.sendSync(i.GET_WINDOW_ID),logger:{debug:(e,...n)=>r.ipcRenderer.send(i.LOG_DEBUG,e,...n),info:(e,...n)=>r.ipcRenderer.send(i.LOG_INFO,e,...n),warn:(e,...n)=>r.ipcRenderer.send(i.LOG_WARN,e,...n),error:(e,...n)=>r.ipcRenderer.send(i.LOG_ERROR,e,...n)},executeScript:e=>r.ipcRenderer.invoke(i.EXECUTE_SCRIPT,e),onTaskProgress:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_PROGRESS,n),()=>r.ipcRenderer.removeListener(i.TASK_PROGRESS,n)},onTaskStarted:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_STARTED,n),()=>r.ipcRenderer.removeListener(i.TASK_STARTED,n)},onTaskCompleted:e=>{const n=(t,o)=>e(o);return r.ipcRenderer.on(i.TASK_COMPLETED,n),()=>r.ipcRenderer.removeListener(i.TASK_COMPLETED,n)},openChannel:e=>r.ipcRenderer.invoke(i.OPEN_CHANNEL,e),scriptApi:{list:()=>r.ipcRenderer.invoke(i.SCRIPT_LIST),get:e=>r.ipcRenderer.invoke(i.SCRIPT_GET,e),save:e=>r.ipcRenderer.invoke(i.SCRIPT_SAVE,e),delete:e=>r.ipcRenderer.invoke(i.SCRIPT_DELETE,e),toggle:(e,n)=>r.ipcRenderer.invoke(i.SCRIPT_TOGGLE,e,n),run:e=>r.ipcRenderer.invoke(i.SCRIPT_RUN,e),startRecording:e=>r.ipcRenderer.invoke(i.SCRIPT_RECORD_START,e),stopRecording:()=>r.ipcRenderer.invoke(i.SCRIPT_RECORD_STOP),codegen:(e,n)=>r.ipcRenderer.invoke(i.SCRIPT_CODEGEN,e,n)}};r.contextBridge.exposeInMainWorld("api",d);
"use strict";
const electron = require("electron");
var IPC_EVENTS = /* @__PURE__ */ ((IPC_EVENTS2) => {
IPC_EVENTS2["EXTERNAL_OPEN"] = "external-open";
IPC_EVENTS2["APP_SET_FRAMELESS"] = "app:set-frameless";
IPC_EVENTS2["APP_LOAD_PAGE"] = "app:load-page";
IPC_EVENTS2["TAB_CREATE"] = "tab:create";
IPC_EVENTS2["TAB_LIST"] = "tab:list";
IPC_EVENTS2["TAB_NAVIGATE"] = "tab:navigate";
IPC_EVENTS2["TAB_RELOAD"] = "tab:reload";
IPC_EVENTS2["TAB_BACK"] = "tab:back";
IPC_EVENTS2["TAB_FORWARD"] = "tab:forward";
IPC_EVENTS2["TAB_SWITCH"] = "tab:switch";
IPC_EVENTS2["TAB_CLOSE"] = "tab:close";
IPC_EVENTS2["LOG_TO_MAIN"] = "log-to-main";
IPC_EVENTS2["READ_FILE"] = "read-file";
IPC_EVENTS2["INVOKE"] = "ipc:invoke";
IPC_EVENTS2["INVOKE_ASYNC"] = "ipc:invokeAsync";
IPC_EVENTS2["APP_MINIMIZE"] = "app:minimize";
IPC_EVENTS2["APP_MAXIMIZE"] = "app:maximize";
IPC_EVENTS2["APP_QUIT"] = "app:quit";
IPC_EVENTS2["FILE_READ"] = "file:read";
IPC_EVENTS2["FILE_WRITE"] = "file:write";
IPC_EVENTS2["GET_WINDOW_ID"] = "get-window-id";
IPC_EVENTS2["CUSTOM_EVENT"] = "custom:event";
IPC_EVENTS2["TIME_UPDATE"] = "time:update";
IPC_EVENTS2["RENDERER_IS_READY"] = "renderer-ready";
IPC_EVENTS2["SHOW_CONTEXT_MENU"] = "show-context-menu";
IPC_EVENTS2["START_A_DIALOGUE"] = "start-a-dialogue";
IPC_EVENTS2["OPEN_WINDOW"] = "open-window";
IPC_EVENTS2["LOG_DEBUG"] = "log-debug";
IPC_EVENTS2["LOG_INFO"] = "log-info";
IPC_EVENTS2["LOG_WARN"] = "log-warn";
IPC_EVENTS2["LOG_ERROR"] = "log-error";
IPC_EVENTS2["CONFIG_UPDATED"] = "config-updated";
IPC_EVENTS2["SET_CONFIG"] = "set-config";
IPC_EVENTS2["GET_CONFIG"] = "get-config";
IPC_EVENTS2["UPDATE_CONFIG"] = "update-config";
IPC_EVENTS2["SET_THEME_MODE"] = "set-theme-mode";
IPC_EVENTS2["GET_THEME_MODE"] = "get-theme-mode";
IPC_EVENTS2["IS_DARK_THEME"] = "is-dark-theme";
IPC_EVENTS2["THEME_MODE_UPDATED"] = "theme-mode-updated";
IPC_EVENTS2["EXECUTE_SCRIPT"] = "execute-script";
IPC_EVENTS2["TASK_PROGRESS"] = "task:progress";
IPC_EVENTS2["TASK_STARTED"] = "task:started";
IPC_EVENTS2["TASK_COMPLETED"] = "task:completed";
IPC_EVENTS2["OPEN_CHANNEL"] = "open-channel";
IPC_EVENTS2["SCRIPT_LIST"] = "script:list";
IPC_EVENTS2["SCRIPT_GET"] = "script:get";
IPC_EVENTS2["SCRIPT_SAVE"] = "script:save";
IPC_EVENTS2["SCRIPT_DELETE"] = "script:delete";
IPC_EVENTS2["SCRIPT_TOGGLE"] = "script:toggle";
IPC_EVENTS2["SCRIPT_RUN"] = "script:run";
IPC_EVENTS2["SCRIPT_RECORD_START"] = "script:record-start";
IPC_EVENTS2["SCRIPT_RECORD_STOP"] = "script:record-stop";
IPC_EVENTS2["SCRIPT_CODEGEN"] = "script:codegen";
IPC_EVENTS2["GATEWAY_RPC"] = "gateway:rpc";
IPC_EVENTS2["GATEWAY_EVENT"] = "gateway:event";
IPC_EVENTS2["UPDATE_CHECK"] = "update:check";
IPC_EVENTS2["UPDATE_DOWNLOAD"] = "update:download";
IPC_EVENTS2["UPDATE_INSTALL"] = "update:install";
IPC_EVENTS2["UPDATE_VERSION"] = "update:version";
IPC_EVENTS2["UPDATE_STATUS_CHANGED"] = "update:status-changed";
return IPC_EVENTS2;
})(IPC_EVENTS || {});
const api = {
versions: process.versions,
external: {
open: (url) => electron.ipcRenderer.invoke("external-open", url)
},
platform: process.platform,
windowMinimize: () => electron.ipcRenderer.invoke("window:minimize"),
windowMaximize: () => electron.ipcRenderer.invoke("window:maximize"),
windowClose: () => electron.ipcRenderer.invoke("window:close"),
windowIsMaximized: () => electron.ipcRenderer.invoke("window:isMaximized"),
viewIsReady: () => electron.ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY),
app: {
setFrameless: (route) => electron.ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route),
loadPage: (page) => electron.ipcRenderer.invoke(IPC_EVENTS.APP_LOAD_PAGE, page)
},
// 通过 IPC 调用主进程
readFile: (filePath) => electron.ipcRenderer.invoke(IPC_EVENTS.READ_FILE, filePath),
// 异步调用(映射为 electron 的 invoke)
invoke: (channel, ...args) => electron.ipcRenderer.invoke(channel, ...args),
// 异步调用(为了兼容老代码)
invokeAsync: (channel, ...args) => electron.ipcRenderer.invoke(channel, ...args),
// 监听主进程消息
on: (event, callback) => {
const subscription = (_event, ...args) => callback(...args);
electron.ipcRenderer.on(event, subscription);
return () => electron.ipcRenderer.removeListener(event, subscription);
},
// 发送消息到主进程
send: (channel, ...args) => electron.ipcRenderer.send(channel, ...args),
// 获取窗口ID
getCurrentWindowId: () => electron.ipcRenderer.sendSync(IPC_EVENTS.GET_WINDOW_ID),
// 发送日志
logger: {
debug: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_DEBUG, message, ...meta),
info: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_INFO, message, ...meta),
warn: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_WARN, message, ...meta),
error: (message, ...meta) => electron.ipcRenderer.send(IPC_EVENTS.LOG_ERROR, message, ...meta)
},
// 执行脚本
executeScript: (params) => electron.ipcRenderer.invoke(IPC_EVENTS.EXECUTE_SCRIPT, params),
// 任务事件
onTaskProgress: (cb) => {
const subscription = (_event, payload) => cb(payload);
electron.ipcRenderer.on(IPC_EVENTS.TASK_PROGRESS, subscription);
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_PROGRESS, subscription);
},
onTaskStarted: (cb) => {
const subscription = (_event, payload) => cb(payload);
electron.ipcRenderer.on(IPC_EVENTS.TASK_STARTED, subscription);
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_STARTED, subscription);
},
onTaskCompleted: (cb) => {
const subscription = (_event, payload) => cb(payload);
electron.ipcRenderer.on(IPC_EVENTS.TASK_COMPLETED, subscription);
return () => electron.ipcRenderer.removeListener(IPC_EVENTS.TASK_COMPLETED, subscription);
},
// 打开渠道
openChannel: (channels) => electron.ipcRenderer.invoke(IPC_EVENTS.OPEN_CHANNEL, channels),
// 脚本管理
scriptApi: {
list: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_LIST),
get: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_GET, id),
save: (input) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_SAVE, input),
delete: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_DELETE, id),
toggle: (id, enabled) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_TOGGLE, id, enabled),
run: (id) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RUN, id),
startRecording: (url) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_START, url),
stopRecording: () => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_RECORD_STOP),
codegen: (id, url) => electron.ipcRenderer.invoke(IPC_EVENTS.SCRIPT_CODEGEN, id, url)
}
};
electron.contextBridge.exposeInMainWorld("api", api);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
));

View File

@@ -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>
),
);

View File

@@ -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}
/>

View File

@@ -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>
);

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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[] {