dashboard-nanobot/frontend/src/hooks/useBotsSync.ts

339 lines
14 KiB
TypeScript

import { useEffect, useRef } from 'react';
import axios from 'axios';
import { useAppStore } from '../store/appStore';
import { APP_ENDPOINTS } from '../config/env';
import type { BotState, ChatMessage } from '../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../modules/dashboard/messageParser';
import { pickLocale } from '../i18n';
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en';
import { buildMonitorWsUrl, getBotAccessPassword } from '../utils/botAccess';
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase();
if (s === 'THINKING' || s === 'TOOL_CALL' || s === 'SUCCESS' || s === 'ERROR') return s;
return 'INFO';
}
function normalizeBusState(isTool: boolean): 'THINKING' | 'TOOL_CALL' {
return isTool ? 'TOOL_CALL' : 'THINKING';
}
function normalizeMedia(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw.map((v) => String(v || '').trim()).filter((v) => v.length > 0);
}
function normalizeFeedback(raw: unknown): 'up' | 'down' | null {
const v = String(raw || '').trim().toLowerCase();
if (v === 'up' || v === 'down') return v;
return null;
}
function normalizeMessageId(raw: unknown): number | undefined {
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
const i = Math.trunc(n);
return i > 0 ? i : undefined;
}
function normalizeChannelName(raw: unknown): string {
const channel = String(raw || '').trim().toLowerCase();
if (channel === 'dashboard_channel' || channel === 'dashboard-channel') return 'dashboard';
return channel;
}
function isLikelyEchoOfUserInput(progressText: string, userText: string): boolean {
const progress = normalizeAssistantMessageText(progressText).replace(/\s+/g, ' ').trim().toLowerCase();
const user = normalizeUserMessageText(userText).replace(/\s+/g, ' ').trim().toLowerCase();
if (!progress || !user) return false;
if (progress === user) return true;
if (user.length < 8) return false;
const hasProcessingPrefix =
/processing message|message from|received message|收到消息|处理消息|用户输入|command/i.test(progress);
if (progress.includes(user) && (hasProcessingPrefix || progress.length <= user.length + 40)) {
return true;
}
return false;
}
function extractToolCallProgressHint(raw: string, isZh: boolean): string | null {
const text = String(raw || '').replace(/<\/?tool_call>/gi, '').trim();
if (!text) return null;
const hasToolCallSignal =
/"name"\s*:/.test(text) && /"arguments"\s*:/.test(text);
if (!hasToolCallSignal) return null;
const nameMatch = text.match(/"name"\s*:\s*"([^"]+)"/);
const toolName = String(nameMatch?.[1] || '').trim();
if (!toolName) return null;
const queryMatch = text.match(/"query"\s*:\s*"([^"]+)"/);
const pathMatch = text.match(/"path"\s*:\s*"([^"]+)"/);
const target = String(queryMatch?.[1] || pathMatch?.[1] || '').trim();
const callLabel = target ? `${toolName}("${target.slice(0, 80)}${target.length > 80 ? '…' : ''}")` : toolName;
return `${isZh ? '工具调用' : 'Tool Call'}\n${callLabel}`;
}
export function useBotsSync(forcedBotId?: string) {
const { activeBots, setBots, updateBotState, addBotLog, addBotMessage, addBotEvent, setBotMessages } = useAppStore();
const socketsRef = useRef<Record<string, WebSocket>>({});
const heartbeatsRef = useRef<Record<string, number>>({});
const lastUserEchoRef = useRef<Record<string, { text: string; ts: number }>>({});
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
const lastProgressRef = useRef<Record<string, { text: string; ts: number }>>({});
const hydratedMessagesRef = useRef<Record<string, boolean>>({});
const isZh = useAppStore((s) => s.locale === 'zh');
const locale = useAppStore((s) => s.locale);
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
const forced = String(forcedBotId || '').trim();
useEffect(() => {
const fetchBots = async () => {
try {
if (forced) {
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`);
setBots(res.data ? [res.data] : []);
return;
}
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data);
} catch (error) {
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
}
};
fetchBots();
const interval = window.setInterval(fetchBots, 5000);
return () => {
window.clearInterval(interval);
};
}, [forced, setBots]);
useEffect(() => {
const botIds = Object.keys(activeBots);
const aliveIds = new Set(botIds);
Object.keys(hydratedMessagesRef.current).forEach((botId) => {
if (!aliveIds.has(botId)) {
delete hydratedMessagesRef.current[botId];
}
});
botIds.forEach((botId) => {
if (hydratedMessagesRef.current[botId]) return;
const bot = activeBots[botId];
if (bot?.has_access_password && !getBotAccessPassword(botId)) return;
hydratedMessagesRef.current[botId] = true;
void (async () => {
try {
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
params: { limit: 300 },
});
const rows = Array.isArray(res.data) ? res.data : [];
const messages: ChatMessage[] = rows
.map((row) => {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
return {
id: normalizeMessageId(row?.id),
role,
text: String(row?.text || ''),
attachments: normalizeMedia(row?.media),
ts: Number(row?.ts || Date.now()),
feedback: normalizeFeedback(row?.feedback),
};
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-300);
setBotMessages(botId, messages);
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
if (lastUser) lastUserEchoRef.current[botId] = { text: lastUser.text, ts: lastUser.ts };
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant) lastAssistantRef.current[botId] = { text: lastAssistant.text, ts: lastAssistant.ts };
} catch (error) {
console.error(`Failed to fetch bot messages for ${botId}`, error);
}
})();
});
}, [activeBots, setBotMessages]);
useEffect(() => {
const runningIds = new Set(
Object.values(activeBots)
.filter((bot) => bot.docker_status === 'RUNNING')
.map((bot) => bot.id),
);
Object.keys(socketsRef.current).forEach((botId) => {
if (!runningIds.has(botId)) {
socketsRef.current[botId].close();
delete socketsRef.current[botId];
}
});
Object.values(activeBots).forEach((bot) => {
if (bot.docker_status !== 'RUNNING') {
return;
}
if (bot.has_access_password && !getBotAccessPassword(bot.id)) {
return;
}
if (socketsRef.current[bot.id]) {
return;
}
const ws = new WebSocket(buildMonitorWsUrl(APP_ENDPOINTS.wsBase, bot.id));
ws.onopen = () => {
const beat = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 15000);
heartbeatsRef.current[bot.id] = beat;
};
ws.onmessage = (event) => {
let data: any;
try {
data = JSON.parse(event.data);
} catch {
return;
}
const sourceChannel = normalizeChannelName(data?.channel || data?.source);
const isDashboardChannel = sourceChannel === 'dashboard';
const payload = data?.payload && typeof data.payload === 'object' ? data.payload : {};
if (data.type === 'AGENT_STATE') {
const state = String(payload.state || data.state || 'INFO');
const messageRaw = String(payload.action_msg || payload.msg || data.action_msg || data.msg || '');
const normalizedState = normalizeState(state);
const fullMessage = normalizeAssistantMessageText(messageRaw);
const message = fullMessage || summarizeProgressText(messageRaw, isZh) || t.stateUpdated;
updateBotState(bot.id, state, message);
addBotEvent(bot.id, {
state: normalizedState,
text: message || t.stateUpdated,
ts: Date.now(),
channel: sourceChannel || undefined,
});
if (isDashboardChannel && fullMessage && normalizedState === 'TOOL_CALL') {
const chatText = `${isZh ? '工具调用' : 'Tool Call'}\n${fullMessage}`;
const now = Date.now();
const prev = lastProgressRef.current[bot.id];
if (!prev || prev.text !== chatText || now - prev.ts > 1200) {
addBotMessage(bot.id, {
role: 'assistant',
text: chatText,
ts: now,
kind: 'progress',
});
lastProgressRef.current[bot.id] = { text: chatText, ts: now };
}
}
return;
}
if (data.type === 'ASSISTANT_MESSAGE') {
if (!isDashboardChannel) return;
const text = normalizeAssistantMessageText(String(data.text || payload.text || payload.content || ''));
const attachments = normalizeMedia(data.media || payload.media);
const messageId = normalizeMessageId(data.message_id || payload.message_id);
if (!text && attachments.length === 0) return;
const now = Date.now();
const prev = lastAssistantRef.current[bot.id];
if (prev && prev.text === text && now - prev.ts < 5000 && attachments.length === 0) return;
lastAssistantRef.current[bot.id] = { text, ts: now };
addBotMessage(bot.id, { id: messageId, role: 'assistant', text, attachments, ts: now, kind: 'final', feedback: null });
updateBotState(bot.id, 'IDLE', '');
addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined });
return;
}
if (data.type === 'BUS_EVENT') {
const content = normalizeAssistantMessageText(String(data.content || payload.content || ''));
const isProgress = Boolean(data.is_progress);
const toolHintFromText = extractToolCallProgressHint(content, isZh);
const isTool = Boolean(data.is_tool) || Boolean(toolHintFromText);
if (isProgress) {
const state = normalizeBusState(isTool);
const progressText = summarizeProgressText(content, isZh);
const fullProgress = toolHintFromText || content || progressText || (isZh ? '处理中...' : 'Processing...');
updateBotState(bot.id, state, fullProgress);
addBotEvent(bot.id, { state, text: fullProgress || t.progress, ts: Date.now(), channel: sourceChannel || undefined });
if (isDashboardChannel) {
const lastUserText = lastUserEchoRef.current[bot.id]?.text || '';
if (!isTool && isLikelyEchoOfUserInput(fullProgress, lastUserText)) {
return;
}
const chatText = isTool ? `${isZh ? '工具调用' : 'Tool Call'}\n${fullProgress}` : fullProgress;
const now = Date.now();
const prev = lastProgressRef.current[bot.id];
if (!prev || prev.text !== chatText || now - prev.ts > 1200) {
addBotMessage(bot.id, {
role: 'assistant',
text: chatText,
ts: now,
kind: 'progress',
});
lastProgressRef.current[bot.id] = { text: chatText, ts: now };
}
}
return;
}
if (!isDashboardChannel) return;
if (content) {
const messageId = normalizeMessageId(data.message_id || payload.message_id);
const now = Date.now();
const prev = lastAssistantRef.current[bot.id];
if (!prev || prev.text !== content || now - prev.ts >= 5000) {
addBotMessage(bot.id, { id: messageId, role: 'assistant', text: content, ts: now, kind: 'final', feedback: null });
lastAssistantRef.current[bot.id] = { text: content, ts: now };
}
updateBotState(bot.id, 'IDLE', summarizeProgressText(content, isZh));
addBotEvent(bot.id, { state: 'SUCCESS', text: t.replied, ts: Date.now(), channel: sourceChannel || undefined });
}
return;
}
if (data.type === 'USER_COMMAND') {
if (!isDashboardChannel) return;
const rawText = String(data.text || payload.text || payload.command || '');
const text = normalizeUserMessageText(rawText);
const attachments = normalizeMedia(data.media || payload.media);
const messageId = normalizeMessageId(data.message_id || payload.message_id);
if (!text && attachments.length === 0) return;
const now = Date.now();
const prev = lastUserEchoRef.current[bot.id];
if (prev && prev.text === text && now - prev.ts < 10000 && attachments.length === 0) return;
lastUserEchoRef.current[bot.id] = { text, ts: now };
addBotMessage(bot.id, { id: messageId, role: 'user', text: rawText, attachments, ts: now, kind: 'final' });
return;
}
if (data.type === 'RAW_LOG') {
addBotLog(bot.id, String(data.text || ''));
}
};
ws.onclose = () => {
const hb = heartbeatsRef.current[bot.id];
if (hb) {
window.clearInterval(hb);
delete heartbeatsRef.current[bot.id];
}
delete socketsRef.current[bot.id];
};
socketsRef.current[bot.id] = ws;
});
return () => {
// no-op: clean in unmount effect below
};
}, [activeBots, addBotEvent, addBotLog, addBotMessage, isZh, t.progress, t.replied, t.stateUpdated, updateBotState]);
useEffect(() => {
return () => {
Object.values(socketsRef.current).forEach((ws) => ws.close());
Object.values(heartbeatsRef.current).forEach((timerId) => window.clearInterval(timerId));
heartbeatsRef.current = {};
socketsRef.current = {};
};
}, []);
}