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; 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( () => 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(() => { const imagesByTag = new Map(); 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, }; }