dashboard-nanobot/frontend/src/modules/dashboard/hooks/useDashboardDerivedState.ts

207 lines
6.7 KiB
TypeScript

import { useMemo } from 'react';
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
import { mergeConversation } from '../chat/chatUtils';
import { RUNTIME_STALE_MS } from '../constants';
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
import type { BaseImageOption, NanobotImage } from '../types';
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
import { normalizeRuntimeState } from '../utils';
interface UseDashboardBaseStateOptions {
availableImages: NanobotImage[];
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
defaultSystemTimezone: string;
editFormImageTag: string;
editFormSystemTimezone: string;
events: any[];
isZh: boolean;
messages: any[];
selectedBot?: any;
topicFeedUnreadCount: number;
topics: any[];
}
interface UseDashboardInteractionStateOptions {
canChat: boolean;
isSendingBlocked?: boolean;
isVoiceRecording?: boolean;
isVoiceTranscribing?: boolean;
selectedBot?: any;
}
export function useDashboardBaseState({
availableImages,
controlStateByBot,
defaultSystemTimezone,
editFormImageTag,
editFormSystemTimezone,
events,
isZh,
messages,
selectedBot,
topicFeedUnreadCount,
topics,
}: UseDashboardBaseStateOptions) {
const activeTopicOptions = useMemo<TopicFeedOption[]>(
() =>
topics
.filter((topic) => Boolean(topic.is_active))
.map((topic) => ({
key: String(topic.topic_key || '').trim().toLowerCase(),
label: String(topic.name || topic.topic_key || '').trim(),
}))
.filter((row) => Boolean(row.key))
.sort((a, b) => a.key.localeCompare(b.key)),
[topics],
);
const topicPanelState = useMemo<'none' | 'inactive' | 'ready'>(() => {
if (topics.length === 0) return 'none';
if (activeTopicOptions.length === 0) return 'inactive';
return 'ready';
}, [activeTopicOptions, topics]);
const baseImageOptions = useMemo<BaseImageOption[]>(() => {
const imagesByTag = new Map<string, NanobotImage>();
availableImages.forEach((img) => {
const tag = String(img.tag || '').trim();
if (!tag || imagesByTag.has(tag)) return;
imagesByTag.set(tag, img);
});
const options = Array.from(imagesByTag.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([tag, img]) => {
const status = String(img.status || '').toUpperCase() || 'UNKNOWN';
return {
tag,
label: `${tag} · ${status}`,
disabled: status !== 'READY',
};
});
const currentTag = String(editFormImageTag || '').trim();
if (currentTag && !options.some((opt) => opt.tag === currentTag)) {
options.unshift({
tag: currentTag,
label: isZh ? `${currentTag} · 未登记(只读)` : `${currentTag} · unregistered (read-only)`,
disabled: true,
});
}
return options;
}, [availableImages, editFormImageTag, isZh]);
const conversation = useMemo(() => mergeConversation(messages), [messages]);
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false);
const canChat = Boolean(
selectedBotEnabled &&
selectedBot &&
selectedBot.docker_status === 'RUNNING' &&
!selectedBotControlState,
);
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
const systemTimezoneOptions = useMemo(
() => getSystemTimezoneOptions(editFormSystemTimezone || defaultSystemTimezone),
[defaultSystemTimezone, editFormSystemTimezone],
);
const lastUserTs = useMemo(
() => [...conversation].reverse().find((message) => message.role === 'user')?.ts || 0,
[conversation],
);
const lastAssistantFinalTs = useMemo(
() =>
[...conversation].reverse().find(
(message) => message.role === 'assistant' && (message.kind || 'final') !== 'progress',
)?.ts || 0,
[conversation],
);
const botUpdatedAtTs = useMemo(
() => parseBotTimestamp(selectedBot?.updated_at),
[selectedBot?.updated_at],
);
const latestRuntimeSignalTs = useMemo(() => {
const latestEventTs = latestEvent?.ts || 0;
return Math.max(latestEventTs, botUpdatedAtTs, lastUserTs);
}, [botUpdatedAtTs, lastUserTs, latestEvent?.ts]);
const hasFreshRuntimeSignal = useMemo(
() => latestRuntimeSignalTs > 0 && Date.now() - latestRuntimeSignalTs < RUNTIME_STALE_MS,
[latestRuntimeSignalTs],
);
const isThinking = useMemo(() => {
if (!selectedBot || selectedBot.docker_status !== 'RUNNING') return false;
if (lastUserTs <= 0) return false;
if (lastAssistantFinalTs >= lastUserTs) return false;
return hasFreshRuntimeSignal;
}, [hasFreshRuntimeSignal, lastAssistantFinalTs, lastUserTs, selectedBot]);
const displayState = useMemo(() => {
if (!selectedBot) return 'IDLE';
const backendState = normalizeRuntimeState(selectedBot.current_state);
if (selectedBot.docker_status !== 'RUNNING') return backendState;
if (
hasFreshRuntimeSignal &&
(backendState === 'TOOL_CALL' || backendState === 'THINKING' || backendState === 'ERROR')
) {
return backendState;
}
if (isThinking) {
if (latestEvent?.state === 'TOOL_CALL') return 'TOOL_CALL';
return 'THINKING';
}
if (
latestEvent &&
['THINKING', 'TOOL_CALL', 'ERROR'].includes(latestEvent.state) &&
Date.now() - latestEvent.ts < 15000
) {
return latestEvent.state;
}
if (latestEvent?.state === 'ERROR') return 'ERROR';
return 'IDLE';
}, [hasFreshRuntimeSignal, isThinking, latestEvent, selectedBot]);
const runtimeAction = useMemo(() => {
const action = normalizeAssistantMessageText(selectedBot?.last_action || '').trim();
if (action) return action;
const eventText = normalizeAssistantMessageText(latestEvent?.text || '').trim();
if (eventText) return eventText;
return '-';
}, [latestEvent, selectedBot]);
const hasTopicUnread = topicFeedUnreadCount > 0;
return {
activeTopicOptions,
baseImageOptions,
canChat,
conversation,
displayState,
hasTopicUnread,
isThinking,
runtimeAction,
selectedBotControlState,
selectedBotEnabled,
systemTimezoneOptions,
topicPanelState,
};
}
export function useDashboardInteractionState({
canChat,
isSendingBlocked = false,
isVoiceRecording = false,
isVoiceTranscribing = false,
selectedBot,
}: UseDashboardInteractionStateOptions) {
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
const canSendControlCommand = Boolean(
selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing,
);
return {
canSendControlCommand,
isChatEnabled,
};
}