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 = new Set(); 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; 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; 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): 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).purpose || '').trim().toLowerCase(); const desc = String(topic.description || '').trim().toLowerCase(); const name = String(topic.name || '').trim().toLowerCase(); const priority = Number((routing as Record).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): 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 = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET, ) { return pathHasExtension(path, downloadExtensions); } export function isPreviewableWorkspaceFile( node: WorkspaceNode, downloadExtensions: ReadonlySet = 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 = 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 = 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(/); } parts.forEach((part, index) => { if (index > 0) { nodes.push(/); } nodes.push({part}); }); 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 | 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 = {}; 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(), }; }); }