dashboard-nanobot/frontend/src/modules/dashboard/utils.tsx

507 lines
19 KiB
TypeScript
Raw Normal View History

2026-03-31 04:31:47 +00:00
import type { ReactNode } from 'react';
import type { ChatMessage } from '../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText } from './messageParser';
import {
AUDIO_PREVIEW_EXTENSIONS,
HTML_PREVIEW_EXTENSIONS,
IMAGE_PREVIEW_EXTENSIONS,
MEDIA_UPLOAD_EXTENSIONS,
SYSTEM_FALLBACK_TOPIC_KEYS,
TEXT_PREVIEW_EXTENSIONS,
VIDEO_PREVIEW_EXTENSIONS,
} from './constants';
import type {
BotTopic,
CronJob,
MCPConfigResponse,
MCPServerDraft,
TopicPresetTemplate,
WorkspaceNode,
} from './types';
import { normalizeDashboardAttachmentPath } from './shared/workspaceMarkdown';
const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: readonly string[] = [];
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET: ReadonlySet<string> = new Set<string>();
export function formatClock(ts: number) {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export function formatConversationDate(ts: number, isZh: boolean) {
const d = new Date(ts);
try {
return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
});
} catch {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
export function formatDateInputValue(ts: number): string {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function mapBotMessageResponseRow(row: any): ChatMessage {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
}
export function stateLabel(s?: string) {
return (s || 'IDLE').toUpperCase();
}
export function resolvePresetText(raw: unknown, locale: 'zh-cn' | 'en'): string {
if (typeof raw === 'string') return raw.trim();
if (!raw || typeof raw !== 'object') return '';
const bag = raw as Record<string, unknown>;
const byLocale = String(bag[locale] || '').trim();
if (byLocale) return byLocale;
return String(bag['zh-cn'] || bag.en || '').trim();
}
export function normalizePresetTextList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const rows: string[] = [];
raw.forEach((item) => {
const text = String(item || '').trim();
if (text) rows.push(text);
});
return rows;
}
export function parseTopicPresets(raw: unknown): TopicPresetTemplate[] {
if (!Array.isArray(raw)) return [];
const rows: TopicPresetTemplate[] = [];
raw.forEach((item) => {
if (!item || typeof item !== 'object') return;
const record = item as Record<string, unknown>;
const id = String(record.id || '').trim().toLowerCase();
const topicKey = String(record.topic_key || '').trim().toLowerCase();
if (!id || !topicKey) return;
const priority = Number(record.routing_priority);
rows.push({
id,
topic_key: topicKey,
name: record.name,
description: record.description,
routing_purpose: record.routing_purpose,
routing_include_when: record.routing_include_when,
routing_exclude_when: record.routing_exclude_when,
routing_examples_positive: record.routing_examples_positive,
routing_examples_negative: record.routing_examples_negative,
routing_priority: Number.isFinite(priority) ? Math.max(0, Math.min(100, Math.round(priority))) : undefined,
});
});
return rows;
}
export function isSystemFallbackTopic(topic: Pick<BotTopic, 'topic_key' | 'name' | 'description' | 'routing'>): boolean {
const key = String(topic.topic_key || '').trim().toLowerCase();
if (!SYSTEM_FALLBACK_TOPIC_KEYS.has(key)) return false;
const routing = topic.routing && typeof topic.routing === 'object' ? topic.routing : {};
const purpose = String((routing as Record<string, unknown>).purpose || '').trim().toLowerCase();
const desc = String(topic.description || '').trim().toLowerCase();
const name = String(topic.name || '').trim().toLowerCase();
const priority = Number((routing as Record<string, unknown>).priority);
if (purpose.includes('fallback')) return true;
if (desc.includes('default topic')) return true;
if (name === 'inbox') return true;
if (Number.isFinite(priority) && priority <= 1) return true;
return false;
}
export function normalizeRuntimeState(s?: string) {
const raw = stateLabel(s);
if (raw.includes('ERROR') || raw.includes('FAIL')) return 'ERROR';
if (raw.includes('TOOL') || raw.includes('EXEC') || raw.includes('ACTION')) return 'TOOL_CALL';
if (raw.includes('THINK') || raw.includes('PLAN') || raw.includes('REASON') || raw === 'RUNNING') return 'THINKING';
if (raw.includes('SUCCESS') || raw.includes('DONE') || raw.includes('COMPLETE')) return 'SUCCESS';
if (raw.includes('IDLE') || raw.includes('STOP')) return 'IDLE';
return raw;
}
export function parseBotTimestamp(raw?: string | number) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
const ms = Date.parse(text);
return Number.isFinite(ms) ? ms : 0;
}
export function normalizeWorkspaceExtension(raw: unknown): string {
const value = String(raw ?? '').trim().toLowerCase();
if (!value) return '';
const stripped = value.replace(/^\*\./, '');
const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`;
return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : '';
}
export function parseWorkspaceDownloadExtensions(
raw: unknown,
fallback: readonly string[] = EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS,
): string[] {
if (raw === null || raw === undefined) return [...fallback];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
export function parseAllowedAttachmentExtensions(raw: unknown): string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw) && raw.length === 0) return [];
if (typeof raw === 'string' && raw.trim() === '') return [];
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
const rows: string[] = [];
source.forEach((item) => {
const ext = normalizeWorkspaceExtension(item);
if (ext && !rows.includes(ext)) rows.push(ext);
});
return rows;
}
export function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
const normalized = String(path || '').trim().toLowerCase();
if (!normalized) return false;
for (const ext of extensions) {
if (normalized.endsWith(ext)) return true;
}
return false;
}
export function isDownloadOnlyPath(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
return pathHasExtension(path, downloadExtensions);
}
export function isPreviewableWorkspaceFile(
node: WorkspaceNode,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (node.type !== 'file') return false;
return isPreviewableWorkspacePath(node.path, downloadExtensions);
}
export function isImagePath(path: string) {
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
}
export function isVideoPath(path: string) {
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
}
export function isAudioPath(path: string) {
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
}
export function isMediaUploadFile(file: File): boolean {
const mime = String(file.type || '').toLowerCase();
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
return true;
}
const name = String(file.name || '').trim().toLowerCase();
const dot = name.lastIndexOf('.');
if (dot < 0) return false;
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
}
export function isHtmlPath(path: string) {
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
}
export function isPreviewableWorkspacePath(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
) {
if (isDownloadOnlyPath(path, downloadExtensions)) return true;
return (
pathHasExtension(path, TEXT_PREVIEW_EXTENSIONS) ||
isHtmlPath(path) ||
isImagePath(path) ||
isAudioPath(path) ||
isVideoPath(path)
);
}
export function workspaceFileAction(
path: string,
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
): 'preview' | 'download' | 'unsupported' {
const normalized = String(path || '').trim();
if (!normalized) return 'unsupported';
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
return 'unsupported';
}
export function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
const path = String(pathRaw || '');
if (!path) return ['-'];
const normalized = path.replace(/\\/g, '/');
const hasLeadingSlash = normalized.startsWith('/');
const parts = normalized.split('/').filter((part) => part.length > 0);
const nodes: ReactNode[] = [];
if (hasLeadingSlash) {
nodes.push(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
}
parts.forEach((part, index) => {
if (index > 0) {
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
}
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
});
return nodes.length > 0 ? nodes : ['-'];
}
export function normalizeAttachmentPaths(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((v) => String(v || '').trim().replace(/\\/g, '/'))
.filter((v) => v.length > 0);
}
export interface ComposerDraftStorage {
command: string;
attachments: string[];
updated_at_ms: number;
}
export function getComposerDraftStorageKey(botId: string): string {
return `${COMPOSER_DRAFT_STORAGE_PREFIX}${String(botId || '').trim()}`;
}
export function loadComposerDraft(botId: string): ComposerDraftStorage | null {
const id = String(botId || '').trim();
if (!id || typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(getComposerDraftStorageKey(id));
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<ComposerDraftStorage> | null;
const command = String(parsed?.command || '');
const attachments = normalizeAttachmentPaths(parsed?.attachments)
.map(normalizeDashboardAttachmentPath)
.filter(Boolean);
return {
command,
attachments,
updated_at_ms: Number(parsed?.updated_at_ms || Date.now()),
};
} catch {
return null;
}
}
export function persistComposerDraft(botId: string, commandRaw: string, attachmentsRaw: string[]): void {
const id = String(botId || '').trim();
if (!id || typeof window === 'undefined') return;
const command = String(commandRaw || '');
const attachments = normalizeAttachmentPaths(attachmentsRaw)
.map(normalizeDashboardAttachmentPath)
.filter(Boolean);
const key = getComposerDraftStorageKey(id);
try {
if (!command.trim() && attachments.length === 0) {
window.localStorage.removeItem(key);
return;
}
const payload: ComposerDraftStorage = {
command,
attachments,
updated_at_ms: Date.now(),
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch {
// ignore localStorage write failures
}
}
export function parseQuotedReplyBlock(input: string): { quoted: string; body: string } {
const source = String(input || '');
const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i);
const quoted = normalizeAssistantMessageText(match?.[1] || '');
const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim();
return { quoted, body };
}
export function mergeConversation(messages: ChatMessage[]) {
const merged: ChatMessage[] = [];
messages
.filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0))
.forEach((msg) => {
const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text };
const userQuoted = parsedUser.quoted;
const userBody = parsedUser.body;
const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text);
const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean);
if (!cleanText && attachments.length === 0 && !userQuoted) return;
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text);
const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText);
const lastKind = last.kind || 'final';
const currentKind = msg.kind || 'final';
const sameAttachmentSet =
JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments);
const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted);
if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) {
last.ts = msg.ts;
last.id = msg.id || last.id;
if (typeof msg.feedback !== 'undefined') {
last.feedback = msg.feedback;
}
return;
}
}
merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments });
});
return merged.slice(-120);
}
export function clampTemperature(value: number) {
if (Number.isNaN(value)) return 0.2;
return Math.min(1, Math.max(0, value));
}
export function clampMaxTokens(value: number) {
if (Number.isNaN(value)) return 8192;
return Math.min(32768, Math.max(256, Math.round(value)));
}
export function clampCpuCores(value: number) {
if (Number.isNaN(value)) return 1;
if (value === 0) return 0;
return Math.min(16, Math.max(0.1, Math.round(value * 10) / 10));
}
export function clampMemoryMb(value: number) {
if (Number.isNaN(value)) return 1024;
if (value === 0) return 0;
return Math.min(65536, Math.max(256, Math.round(value)));
}
export function clampStorageGb(value: number) {
if (Number.isNaN(value)) return 10;
if (value === 0) return 0;
return Math.min(1024, Math.max(1, Math.round(value)));
}
export function formatBytes(bytes: number): string {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
const scaled = value / Math.pow(1024, idx);
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`;
}
export function formatPercent(value: number): string {
const n = Number(value || 0);
if (!Number.isFinite(n)) return '0.00%';
return `${Math.max(0, n).toFixed(2)}%`;
}
export function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string {
const text = String(raw || '').trim();
if (!text) return '-';
const dt = new Date(text);
if (Number.isNaN(dt.getTime())) return '-';
try {
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return dt.toLocaleString();
}
}
export function formatCronSchedule(job: CronJob, isZh: boolean) {
const s = job.schedule || {};
if (s.kind === 'every' && Number(s.everyMs) > 0) {
const sec = Math.round(Number(s.everyMs) / 1000);
return isZh ? `${sec}s` : `every ${sec}s`;
}
if (s.kind === 'cron') {
if (s.tz) return `${s.expr || '-'} (${s.tz})`;
return s.expr || '-';
}
if (s.kind === 'at' && Number(s.atMs) > 0) {
return new Date(Number(s.atMs)).toLocaleString();
}
return '-';
}
export function mapMcpResponseToDrafts(payload?: MCPConfigResponse | null): MCPServerDraft[] {
const rows = payload?.mcp_servers && typeof payload.mcp_servers === 'object' ? payload.mcp_servers : {};
return Object.entries(rows).map(([name, cfg]) => {
const rawHeaders = cfg?.headers && typeof cfg.headers === 'object' ? cfg.headers : {};
const headers: Record<string, string> = {};
Object.entries(rawHeaders).forEach(([k, v]) => {
const key = String(k || '').trim();
if (!key) return;
headers[key] = String(v ?? '').trim();
});
const headerEntries = Object.entries(headers);
const botIdHeader = headerEntries.find(([k]) => String(k || '').trim().toLowerCase() === 'x-bot-id');
const botSecretHeader = headerEntries.find(([k]) => String(k || '').trim().toLowerCase() === 'x-bot-secret');
const type: MCPServerDraft['type'] = String(cfg?.type || 'streamableHttp') === 'sse' ? 'sse' : 'streamableHttp';
return {
name: String(name || '').trim(),
type,
url: String(cfg?.url || '').trim(),
botId: String(botIdHeader?.[1] || '').trim(),
botSecret: String(botSecretHeader?.[1] || '').trim(),
toolTimeout: String(Number(cfg?.toolTimeout || 60) || 60),
headers,
locked: Boolean(cfg?.locked),
originName: String(name || '').trim(),
};
});
}