Files
zn-ai/src/pages/Home/index.tsx
DEV_DSW 79bea4f107 Refactor UUID generation, remove unused logger and encryption utilities, and clean up request handling
- Updated `generateUUID` function for improved readability and performance.
- Deleted `logger.ts`, `other.ts`, `request.ts`, `storage.ts`, `tansParams.ts`, and `validate.ts` as they were no longer needed.
- Simplified TypeScript configuration by removing unnecessary paths and aliases.
- Enhanced Vite configuration for better project structure and maintainability.
2026-04-17 15:38:08 +08:00

447 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import type { RawMessage } from '../../shared/chat-model';
import { extractText, isInternalMessage } from '../../shared/chat-model';
import { taskCenterList, type TaskCenterItem } from '../../constants/taskCenterList';
import {
ChatComposer,
ChatHistoryPanel,
ChatMessageList,
TaskBoard,
type ChatHistoryBucket,
type ChatMessageItem,
type TaskItem,
type TaskTabValue,
} from '../../components/chat';
import { IPC_EVENTS } from '../../lib/constants';
import { invokeIpc } from '../../lib/host-api';
import {
channelStore,
chatStore,
getCompletedTasks,
getPendingTasks,
taskStore,
useChannelStore,
useChatStore,
useTaskStore,
type StagedAttachment,
} from '../../stores';
import { AddChannelDialog, TaskOperationDialog } from './components';
type SessionBucketKey = 'today' | 'yesterday' | 'withinWeek' | 'withinTwoWeeks' | 'withinMonth' | 'older';
const HISTORY_BUCKET_META: Array<{ key: SessionBucketKey; label: string }> = [
{ key: 'today', label: '今天' },
{ key: 'yesterday', label: '昨天' },
{ key: 'withinWeek', label: '近7天' },
{ key: 'withinTwoWeeks', label: '近14天' },
{ key: 'withinMonth', label: '近30天' },
{ key: 'older', label: '更早' },
];
function getMessageTime(timestamp?: number): string {
if (!timestamp) return '--';
const date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
}
function getHistoryBucket(activityMs: number, currentMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(currentMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
function formatHistoryTime(activityMs: number, currentMs: number): string {
if (!activityMs || activityMs <= 0) return '--';
const date = new Date(activityMs);
const current = new Date(currentMs);
const sameDay = date.toDateString() === current.toDateString();
if (sameDay) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
const yesterday = new Date(current);
yesterday.setDate(current.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function getTaskDateLabel(createdAt?: string): string {
if (!createdAt) return '执行时段';
const current = new Date(createdAt);
const today = new Date();
const y = today.getFullYear();
const m = today.getMonth();
const d = today.getDate();
const cy = current.getFullYear();
const cm = current.getMonth();
const cd = current.getDate();
if (cy === y && cm === m && cd === d) return '今天';
const yesterday = new Date(y, m, d - 1);
if (cy === yesterday.getFullYear() && cm === yesterday.getMonth() && cd === yesterday.getDate()) {
return '昨天';
}
return `${String(cm + 1).padStart(2, '0')}/${String(cd).padStart(2, '0')}`;
}
function getTaskRoomTypeLabel(task: { roomType: string; roomList: Array<{ id?: string; pmsName?: string }> }): string {
const matchedRoom = Array.isArray(task.roomList)
? task.roomList.find((item) => item.id === task.roomType)
: null;
return matchedRoom?.pmsName || task.roomType;
}
function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null): ChatMessageItem[] {
const visibleMessages = messages
.filter((message) => !isInternalMessage(message))
.filter((message) => {
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),
}));
if (streamingMessage && extractText(streamingMessage).trim()) {
visibleMessages.push({
id: streamingMessage.id || `stream-${Date.now()}`,
role: 'assistant',
name: 'YINIAN',
time: getMessageTime(streamingMessage.timestamp),
content: extractText(streamingMessage),
attachments: streamingMessage._attachedFiles,
isStreaming: true,
});
}
return visibleMessages;
}
export default function HomePage() {
const chat = useChatStore();
const taskState = useTaskStore();
const channelState = useChannelStore();
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 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);
async function handleSendMessage(): Promise<void> {
const sent = await chatStore.sendMessage(inputMessage, attachments);
if (sent) {
setInputMessage('');
setAttachments([]);
}
}
async function handleAttach(files: File[]): Promise<void> {
const stagedFiles = await chatStore.stageAttachmentFiles(files);
setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
}
async function handleTaskCenterItem(item: TaskCenterItem): Promise<void> {
setTaskCenterNotice(null);
if (item.type === 'channel') {
if (!channelState.selectedChannels.length) {
setTaskCenterNotice('请先在当前项目里配置“已选渠道”,再执行一键打开。');
return;
}
try {
await invokeIpc(IPC_EVENTS.OPEN_CHANNEL, channelState.selectedChannels);
setTaskCenterNotice('已触发“打开渠道”操作。');
} catch (error) {
setTaskCenterNotice(error instanceof Error ? error.message : String(error));
}
return;
}
setTaskDialogOpen(true);
}
async function handleSaveChannels(items: typeof channelState.selectedChannels): Promise<void> {
await channelStore.saveSelectedChannels(items);
setTaskCenterNotice('已更新“打开渠道”配置。');
setAddChannelDialogOpen(false);
}
async function handleCreateTask(options: Parameters<typeof taskStore.createAndExecuteTask>[0]): Promise<void> {
const { task, result } = await taskStore.createAndExecuteTask(options);
setTaskDialogOpen(false);
if (result.success) {
setTaskCenterNotice(`已创建任务“${task.title}”。`);
return;
}
setTaskCenterNotice(result.error || `任务“${task.title}”创建后执行失败。`);
}
async function handleRetryTask(taskId: string): Promise<void> {
const result = await taskStore.retryFailedSubTasks(taskId);
if (result.success) {
setTaskCenterNotice('已重新触发失败子任务。');
return;
}
setTaskCenterNotice(result.error || '重试失败,请稍后再试。');
}
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">
<ChatHistoryPanel
buckets={historyBuckets}
loading={!chat.initialized}
selectedConversationId={chat.currentSessionKey}
onNewChat={() => {
void chatStore.newSession();
}}
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);
}
}}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] dark:bg-[#1b1b1d]">
<div className="flex items-center justify-between border-b border-[#edf2f7] px-6 py-4 dark:border-[#2a2a2d]">
<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' ? '重连中' : '未连接'}
</div>
</div>
<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 chatStore.loadSessions();
void chatStore.loadHistory();
}}
>
</button>
</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();
}}
/>
</div>
<div className="border-t border-[#edf2f7] dark:border-[#2a2a2d]">
<div className="px-4 py-3">
<div className="flex items-center justify-between pb-3">
<h3 className="text-base font-semibold text-[#171717] dark:text-gray-100"></h3>
<div className="text-xs text-[#99A0AE] dark:text-gray-500">沿</div>
</div>
{taskCenterNotice ? (
<div className="mb-3 rounded-[12px] border border-[#dfeaf6] bg-[#f8fbff] px-4 py-3 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300">
{taskCenterNotice}
</div>
) : null}
<div className="grid grid-cols-2 gap-4 max-[800px]:grid-cols-1">
{taskCenterList.map((item) => (
<button
key={item.id}
type="button"
className="relative flex items-start gap-3 rounded-[10px] border border-[#dfeaf6] bg-white p-3.5 text-left transition-colors hover:bg-[#F5F7FA] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
onClick={() => {
void handleTaskCenterItem(item);
}}
>
{item.type === 'channel' ? (
<span
role="button"
tabIndex={0}
className="absolute right-2 top-2 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#525866] dark:hover:text-gray-200"
onClick={(event) => {
event.stopPropagation();
setTaskCenterNotice(null);
void channelStore.refreshAvailableChannels();
setAddChannelDialogOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
setTaskCenterNotice(null);
void channelStore.refreshAvailableChannels();
setAddChannelDialogOpen(true);
}
}}
aria-label="配置渠道"
>
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-none stroke-current" strokeWidth="1.8">
<path
d="M12 3V6M12 18V21M4.93 4.93L7.05 7.05M16.95 16.95L19.07 19.07M3 12H6M18 12H21M4.93 19.07L7.05 16.95M16.95 7.05L19.07 4.93M15 12A3 3 0 1 1 9 12A3 3 0 0 1 15 12Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
) : null}
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-dashed border-[#9fc0e8] bg-[#EFF6FF] text-[23px] text-[#3b82f6] dark:border-gray-700 dark:bg-[#222225]">
{item.icon}
</div>
<div>
<div className="font-semibold text-[#171717] dark:text-gray-100">{item.title}</div>
<div className="mt-1.5 text-[13px] text-[#9aa5b1] dark:text-gray-400">{item.desc}</div>
</div>
</button>
))}
</div>
</div>
</div>
</div>
<TaskBoard
activeTab={activeTaskTab}
completedItems={completedTaskItems}
currentDateLabel={latestTask ? getTaskDateLabel(latestTask.createdAt) : '执行时段'}
currentTime={latestTask ? `${latestTask.dateRange[0]} ~ ${latestTask.dateRange[1]}` : '--'}
pendingItems={pendingTaskItems}
onRemoveTask={(taskId) => {
taskStore.removeTask(taskId);
}}
onRetryTask={(taskId) => {
void handleRetryTask(taskId);
}}
onTabChange={setActiveTaskTab}
/>
</div>
<TaskOperationDialog
open={taskDialogOpen}
onClose={() => setTaskDialogOpen(false)}
onConfirm={handleCreateTask}
/>
<AddChannelDialog
open={addChannelDialogOpen}
loading={channelState.loading}
availableChannels={channelState.availableChannels}
initialSelected={channelState.selectedChannels}
onClose={() => setAddChannelDialogOpen(false)}
onConfirm={handleSaveChannels}
/>
</section>
);
}