2026-03-31 04:31:47 +00:00
|
|
|
import { useMemo } from 'react';
|
|
|
|
|
|
2026-04-03 15:00:08 +00:00
|
|
|
import { parseBotTimestamp } from '../../../shared/bot/sortBots';
|
2026-03-31 04:31:47 +00:00
|
|
|
import { getSystemTimezoneOptions } from '../../../utils/systemTimezones';
|
2026-04-03 15:00:08 +00:00
|
|
|
import { mergeConversation } from '../chat/chatUtils';
|
2026-03-31 04:31:47 +00:00
|
|
|
import { RUNTIME_STALE_MS } from '../constants';
|
2026-04-03 15:00:08 +00:00
|
|
|
import { normalizeAssistantMessageText } from '../../../shared/text/messageText';
|
2026-03-31 04:31:47 +00:00
|
|
|
import type { BaseImageOption, NanobotImage } from '../types';
|
|
|
|
|
import type { TopicFeedOption } from '../topic/TopicFeedPanel';
|
2026-04-03 15:00:08 +00:00
|
|
|
import { normalizeRuntimeState } from '../utils';
|
2026-03-31 04:31:47 +00:00
|
|
|
|
2026-04-03 15:00:08 +00:00
|
|
|
interface UseDashboardBaseStateOptions {
|
2026-03-31 04:31:47 +00:00
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 15:00:08 +00:00
|
|
|
interface UseDashboardInteractionStateOptions {
|
|
|
|
|
canChat: boolean;
|
|
|
|
|
isSendingBlocked?: boolean;
|
|
|
|
|
isVoiceRecording?: boolean;
|
|
|
|
|
isVoiceTranscribing?: boolean;
|
|
|
|
|
selectedBot?: any;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useDashboardBaseState({
|
2026-03-31 04:31:47 +00:00
|
|
|
availableImages,
|
|
|
|
|
controlStateByBot,
|
|
|
|
|
defaultSystemTimezone,
|
|
|
|
|
editFormImageTag,
|
|
|
|
|
editFormSystemTimezone,
|
|
|
|
|
events,
|
|
|
|
|
isZh,
|
|
|
|
|
messages,
|
|
|
|
|
selectedBot,
|
|
|
|
|
topicFeedUnreadCount,
|
|
|
|
|
topics,
|
2026-04-03 15:00:08 +00:00
|
|
|
}: UseDashboardBaseStateOptions) {
|
2026-03-31 04:31:47 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-03 15:00:08 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|