507 lines
19 KiB
TypeScript
507 lines
19 KiB
TypeScript
|
|
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(),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|