Files
zn-ai/src/pages/Home/index.tsx
duanshuwen e9f3a29886 feat: implement OpenClaw process owner and runtime path utilities
- Add OpenClawProcessOwner class to manage the lifecycle of the OpenClaw process.
- Introduce utility functions for managing OpenClaw runtime paths.
- Update session store to normalize agent session keys and migrate existing keys.
- Refactor main process to handle local provider API routing through a new dispatch function.
- Enhance token usage writer to utilize a new session key parsing function.
- Create agents management store to handle agent data and interactions.
- Update chat store to integrate agent selection and session management.
- Introduce AgentsSection component for displaying agent information in the UI.
- Refactor HomePage to support agent selection and display current agent.
- Update routing to reflect new agents page structure.
2026-04-17 21:32:06 +08:00

476 lines
19 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 {
agentsStore,
channelStore,
chatStore,
getCompletedTasks,
getPendingTasks,
taskStore,
useAgentsStore,
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 agentsState = useAgentsStore();
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 agentsStore.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 selectedAgentId = agentsState.agents.some((agent) => agent.id === chat.currentAgentId)
? chat.currentAgentId
: agentsState.defaultAgentId;
const currentAgent = agentsState.agents.find((agent) => agent.id === selectedAgentId) || null;
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(selectedAgentId || 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);
}
}}
/>
<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' ? '重连中' : '未连接'}
{currentAgent ? ` · 当前代理:${currentAgent.name}` : ''}
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-[#525866] dark:text-gray-300">
<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={agentsState.loading || agentsState.agents.length === 0}
value={selectedAgentId}
onChange={(event) => {
chatStore.selectAgent(event.target.value);
}}
>
{agentsState.agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</label>
<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 agentsStore.load();
void chatStore.loadSessions();
void chatStore.loadHistory();
}}
>
</button>
</div>
</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>
);
}