import { useCallback, useEffect, useMemo, useRef, useState, type AnchorHTMLAttributes, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import axios from 'axios';
import { Activity, ArrowUp, Boxes, Check, ChevronLeft, ChevronRight, Clock3, Copy, Download, EllipsisVertical, ExternalLink, Eye, EyeOff, FileText, FolderOpen, Gauge, Hammer, Lock, Maximize2, MessageSquareText, Mic, Minimize2, Paperclip, Pencil, Plus, Power, PowerOff, RefreshCw, Repeat2, Reply, Save, Search, Settings2, SlidersHorizontal, Square, ThumbsDown, ThumbsUp, TriangleAlert, Trash2, UserRound, Waypoints, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { APP_ENDPOINTS } from '../../config/env';
import { useAppStore } from '../../store/appStore';
import type { ChatMessage } from '../../types/bot';
import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from './messageParser';
import nanobotLogo from '../../assets/nanobot-logo.png';
import './BotDashboardModule.css';
import { channelsZhCn } from '../../i18n/channels.zh-cn';
import { channelsEn } from '../../i18n/channels.en';
import { pickLocale } from '../../i18n';
import { dashboardZhCn } from '../../i18n/dashboard.zh-cn';
import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';

interface BotDashboardModuleProps {
  onOpenCreateWizard?: () => void;
  onOpenImageFactory?: () => void;
  forcedBotId?: string;
  compactMode?: boolean;
}

type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type WorkspaceNodeType = 'dir' | 'file';
type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
type RuntimeViewMode = 'visual' | 'text';
type CompactPanelTab = 'chat' | 'runtime';
type QuotedReply = { id?: number; text: string; ts: number };
const BOT_LIST_PAGE_SIZE = 8;

interface WorkspaceNode {
  name: string;
  path: string;
  type: WorkspaceNodeType;
  size?: number;
  ext?: string;
  ctime?: string;
  mtime?: string;
  children?: WorkspaceNode[];
}

interface WorkspaceHoverCardState {
  node: WorkspaceNode;
  top: number;
  left: number;
  above: boolean;
}

interface WorkspaceTreeResponse {
  bot_id: string;
  root: string;
  cwd: string;
  parent: string | null;
  entries: WorkspaceNode[];
}

interface WorkspaceFileResponse {
  bot_id: string;
  path: string;
  size: number;
  is_markdown: boolean;
  truncated: boolean;
  content: string;
}

interface WorkspacePreviewState {
  path: string;
  content: string;
  truncated: boolean;
  ext: string;
  isMarkdown: boolean;
  isImage: boolean;
  isHtml: boolean;
  isVideo: boolean;
  isAudio: boolean;
}

interface WorkspaceUploadResponse {
  bot_id: string;
  files: Array<{ name: string; path: string; size: number }>;
}

interface CronJob {
  id: string;
  name: string;
  enabled?: boolean;
  schedule?: {
    kind?: 'at' | 'every' | 'cron' | string;
    atMs?: number;
    everyMs?: number;
    expr?: string;
    tz?: string;
  };
  payload?: {
    message?: string;
    channel?: string;
    to?: string;
  };
  state?: {
    nextRunAtMs?: number;
    lastRunAtMs?: number;
    lastStatus?: string;
    lastError?: string;
  };
}

interface CronJobsResponse {
  bot_id: string;
  version: number;
  jobs: CronJob[];
}

interface BotChannel {
  id: string | number;
  bot_id: string;
  channel_type: ChannelType | string;
  external_app_id: string;
  app_secret: string;
  internal_port: number;
  is_active: boolean;
  extra_config: Record<string, unknown>;
  locked?: boolean;
}

interface NanobotImage {
  tag: string;
  status: string;
}

interface DockerImage {
  tag: string;
  version?: string;
  image_id?: string;
}

interface BaseImageOption {
  tag: string;
  label: string;
  disabled: boolean;
  needsRegister: boolean;
}

interface WorkspaceSkillOption {
  id: string;
  name: string;
  type: 'dir' | 'file' | string;
  path: string;
  size?: number | null;
  mtime?: string;
  description?: string;
}

interface BotResourceSnapshot {
  bot_id: string;
  docker_status: string;
  configured: {
    cpu_cores: number;
    memory_mb: number;
    storage_gb: number;
  };
  runtime: {
    docker_status: string;
    limits: {
      cpu_cores?: number | null;
      memory_bytes?: number | null;
      storage_bytes?: number | null;
      nano_cpus?: number;
      storage_opt_raw?: string;
    };
    usage: {
      cpu_percent: number;
      memory_bytes: number;
      memory_limit_bytes: number;
      memory_percent: number;
      network_rx_bytes: number;
      network_tx_bytes: number;
      blk_read_bytes: number;
      blk_write_bytes: number;
      pids: number;
      container_rw_bytes: number;
    };
  };
  workspace: {
    path: string;
    usage_bytes: number;
    configured_limit_bytes?: number | null;
    usage_percent: number;
  };
  enforcement: {
    cpu_limited: boolean;
    memory_limited: boolean;
    storage_limited: boolean;
  };
  note: string;
  collected_at: string;
}

interface SkillUploadResponse {
  status: string;
  bot_id: string;
  installed: string[];
  skills: WorkspaceSkillOption[];
}

interface SystemDefaultsResponse {
  limits?: {
    upload_max_mb?: number;
  };
}

type BotEnvParams = Record<string, string>;

const providerPresets: Record<string, { model: string; apiBase?: string; note: { 'zh-cn': string; en: string } }> = {
  openrouter: {
    model: 'openai/gpt-4o-mini',
    apiBase: 'https://openrouter.ai/api/v1',
    note: {
      'zh-cn': 'OpenRouter 网关，模型示例 openai/gpt-4o-mini',
      en: 'OpenRouter gateway, model example: openai/gpt-4o-mini',
    },
  },
  dashscope: {
    model: 'qwen-plus',
    apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
    note: {
      'zh-cn': '阿里云 DashScope（千问），模型示例 qwen-plus',
      en: 'Alibaba DashScope (Qwen), model example: qwen-plus',
    },
  },
  openai: {
    model: 'gpt-4o-mini',
    note: {
      'zh-cn': 'OpenAI 原生接口',
      en: 'OpenAI native endpoint',
    },
  },
  deepseek: {
    model: 'deepseek-chat',
    note: {
      'zh-cn': 'DeepSeek 原生接口',
      en: 'DeepSeek native endpoint',
    },
  },
  kimi: {
    model: 'moonshot-v1-8k',
    apiBase: 'https://api.moonshot.cn/v1',
    note: {
      'zh-cn': 'Kimi（Moonshot）接口，模型示例 moonshot-v1-8k',
      en: 'Kimi (Moonshot) endpoint, model example: moonshot-v1-8k',
    },
  },
  minimax: {
    model: 'MiniMax-Text-01',
    apiBase: 'https://api.minimax.chat/v1',
    note: {
      'zh-cn': 'MiniMax 接口，模型示例 MiniMax-Text-01',
      en: 'MiniMax endpoint, model example: MiniMax-Text-01',
    },
  },
};
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack'];
const RUNTIME_STALE_MS = 45000;

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}`;
}

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}`;
  }
}

function stateLabel(s?: string) {
  return (s || 'IDLE').toUpperCase();
}

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;
}

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;
}

function isPreviewableWorkspaceFile(node: WorkspaceNode) {
  if (node.type !== 'file') return false;
  const ext = (node.ext || '').trim().toLowerCase();
  if (ext) {
    return [
      '.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf',
      '.png', '.jpg', '.jpeg', '.webp',
      '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
      '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
      '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps',
    ].includes(ext);
  }
  return isPreviewableWorkspacePath(node.path);
}

function isPdfPath(path: string) {
  return String(path || '').trim().toLowerCase().endsWith('.pdf');
}

function isImagePath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return normalized.endsWith('.png') || normalized.endsWith('.jpg') || normalized.endsWith('.jpeg') || normalized.endsWith('.webp');
}

function isVideoPath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts'].some((ext) => normalized.endsWith(ext));
}

function isAudioPath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return ['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma'].some((ext) => normalized.endsWith(ext));
}

const MEDIA_UPLOAD_EXTENSIONS = new Set([
  '.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
  '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
  '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
]);

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));
}

function isHtmlPath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return normalized.endsWith('.html') || normalized.endsWith('.htm');
}

function isOfficePath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return ['.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
    normalized.endsWith(ext),
  );
}

function isPreviewableWorkspacePath(path: string) {
  const normalized = String(path || '').trim().toLowerCase();
  return ['.md', '.json', '.log', '.txt', '.csv', '.html', '.htm', '.pdf', '.png', '.jpg', '.jpeg', '.webp', '.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts', '.doc', '.docx', '.xls', '.xlsx', '.xlsm', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.wps'].some((ext) =>
    normalized.endsWith(ext),
  );
}

function workspaceFileAction(path: string): 'preview' | 'download' | 'unsupported' {
  const normalized = String(path || '').trim();
  if (!normalized) return 'unsupported';
  if (isPdfPath(normalized) || isOfficePath(normalized)) return 'download';
  if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
  const lower = normalized.toLowerCase();
  if (['.md', '.json', '.log', '.txt', '.csv'].some((ext) => lower.endsWith(ext))) return 'preview';
  return 'unsupported';
}

const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
const WORKSPACE_ABS_PATH_PATTERN = /\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],，。！？；：]+/gi;
const WORKSPACE_RELATIVE_PATH_PATTERN =
  /(^|[\s(\[])(\/[^\s<>"'`)\]]+\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg))(?![A-Za-z0-9_./-])/gim;

function buildWorkspaceLink(path: string) {
  return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
}

function parseWorkspaceLink(href: string): string | null {
  const link = String(href || '').trim();
  if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
  const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
  try {
    const decoded = decodeURIComponent(encoded || '').trim();
    return decoded || null;
  } catch {
    return null;
  }
}

function decorateWorkspacePathsForMarkdown(text: string) {
  const source = String(text || '');
  const normalizedExistingLinks = source.replace(
    /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\s*\n?\s*\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)/gi,
    (_full, markdownPath: string) => {
      const normalized = normalizeDashboardAttachmentPath(markdownPath);
      if (!normalized) return String(_full || '');
      return `[${markdownPath}](${buildWorkspaceLink(normalized)})`;
    },
  );
  const withAbsoluteLinks = normalizedExistingLinks.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
    const normalized = normalizeDashboardAttachmentPath(fullPath);
    if (!normalized) return fullPath;
    return `[${fullPath}](${buildWorkspaceLink(normalized)})`;
  });
  return withAbsoluteLinks.replace(WORKSPACE_RELATIVE_PATH_PATTERN, (full, prefix: string, rawPath: string) => {
    const normalized = normalizeDashboardAttachmentPath(rawPath);
    if (!normalized) return full;
    return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`;
  });
}

function normalizeAttachmentPaths(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  return raw
    .map((v) => String(v || '').trim().replace(/\\/g, '/'))
    .filter((v) => v.length > 0);
}

function normalizeDashboardAttachmentPath(path: string): string {
  const v = String(path || '').trim().replace(/\\/g, '/');
  if (!v) return '';
  const prefix = '/root/.nanobot/workspace/';
  if (v.startsWith(prefix)) return v.slice(prefix.length);
  return v.replace(/^\/+/, '');
}

const COMPOSER_DRAFT_STORAGE_PREFIX = 'nanobot-dashboard-composer-draft:v1:';

interface ComposerDraftStorage {
  command: string;
  attachments: string[];
  updated_at_ms: number;
}

function getComposerDraftStorageKey(botId: string): string {
  return `${COMPOSER_DRAFT_STORAGE_PREFIX}${String(botId || '').trim()}`;
}

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;
  }
}

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
  }
}

function isExternalHttpLink(href: string): boolean {
  return /^https?:\/\//i.test(String(href || '').trim());
}

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 };
}

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);
}

function clampTemperature(value: number) {
  if (Number.isNaN(value)) return 0.2;
  return Math.min(1, Math.max(0, value));
}

function clampMaxTokens(value: number) {
  if (Number.isNaN(value)) return 8192;
  return Math.min(32768, Math.max(256, Math.round(value)));
}

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));
}

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)));
}

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)));
}

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]}`;
}

function formatPercent(value: number): string {
  const n = Number(value || 0);
  if (!Number.isFinite(n)) return '0.00%';
  return `${Math.max(0, n).toFixed(2)}%`;
}

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();
  }
}

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 BotDashboardModule({
  onOpenCreateWizard,
  onOpenImageFactory,
  forcedBotId,
  compactMode = false,
}: BotDashboardModuleProps) {
  const {
    activeBots,
    setBots,
    mergeBot,
    updateBotStatus,
    locale,
    addBotMessage,
    setBotMessages,
    setBotMessageFeedback,
  } = useAppStore();
  const { notify, confirm } = useLucentPrompt();
  const fileNotPreviewableLabel = locale === 'zh' ? '当前文件类型不支持预览' : 'This file type is not previewable';
  const [selectedBotId, setSelectedBotId] = useState('');
  const [command, setCommand] = useState('');
  const [isSaving, setIsSaving] = useState(false);
  const [showBaseModal, setShowBaseModal] = useState(false);
  const [showParamModal, setShowParamModal] = useState(false);
  const [showChannelModal, setShowChannelModal] = useState(false);
  const [showSkillsModal, setShowSkillsModal] = useState(false);
  const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
  const [showCronModal, setShowCronModal] = useState(false);
  const [showAgentModal, setShowAgentModal] = useState(false);
  const [showResourceModal, setShowResourceModal] = useState(false);
  const [resourceBotId, setResourceBotId] = useState('');
  const [resourceSnapshot, setResourceSnapshot] = useState<BotResourceSnapshot | null>(null);
  const [resourceLoading, setResourceLoading] = useState(false);
  const [resourceError, setResourceError] = useState('');
  const [agentTab, setAgentTab] = useState<AgentTab>('AGENTS');
  const [isTestingProvider, setIsTestingProvider] = useState(false);
  const [providerTestResult, setProviderTestResult] = useState('');
  const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
  const [sendingByBot, setSendingByBot] = useState<Record<string, boolean>>({});
  const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
  const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
  const chatBottomRef = useRef<HTMLDivElement | null>(null);
  const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
  const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
  const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
  const [workspaceLoading, setWorkspaceLoading] = useState(false);
  const [workspaceError, setWorkspaceError] = useState('');
  const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState('');
  const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
  const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
  const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
  const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
  const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
  const [workspaceQuery, setWorkspaceQuery] = useState('');
  const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
  const [composerDraftHydrated, setComposerDraftHydrated] = useState(false);
  const [quotedReply, setQuotedReply] = useState<QuotedReply | null>(null);
  const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
  const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
  const filePickerRef = useRef<HTMLInputElement | null>(null);
  const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
  const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
  const [cronLoading, setCronLoading] = useState(false);
  const [cronActionJobId, setCronActionJobId] = useState<string>('');
  const [channels, setChannels] = useState<BotChannel[]>([]);
  const [botSkills, setBotSkills] = useState<WorkspaceSkillOption[]>([]);
  const [isSkillUploading, setIsSkillUploading] = useState(false);
  const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
  const [envParams, setEnvParams] = useState<BotEnvParams>({});
  const [envDraftKey, setEnvDraftKey] = useState('');
  const [envDraftValue, setEnvDraftValue] = useState('');
  const [envDraftVisible, setEnvDraftVisible] = useState(false);
  const [envVisibleByKey, setEnvVisibleByKey] = useState<Record<string, boolean>>({});
  const [isSavingChannel, setIsSavingChannel] = useState(false);
  const [isSavingGlobalDelivery, setIsSavingGlobalDelivery] = useState(false);
  const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
  const [localDockerImages, setLocalDockerImages] = useState<DockerImage[]>([]);
  const [globalDelivery, setGlobalDelivery] = useState<{ sendProgress: boolean; sendToolHints: boolean }>({
    sendProgress: false,
    sendToolHints: false,
  });
  const [uploadMaxMb, setUploadMaxMb] = useState(100);
  const [newChannelType, setNewChannelType] = useState<ChannelType>('feishu');
  const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
  const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
  const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>('chat');
  const [isCompactMobile, setIsCompactMobile] = useState(false);
  const [botListQuery, setBotListQuery] = useState('');
  const [botListPage, setBotListPage] = useState(1);
  const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
  const [feedbackSavingByMessageId, setFeedbackSavingByMessageId] = useState<Record<number, boolean>>({});
  const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
  const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
  const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
  const botOrderRef = useRef<Record<string, number>>({});
  const nextBotOrderRef = useRef(1);
  const applyEditFormFromBot = useCallback((bot?: any) => {
    if (!bot) return;
    setProviderTestResult('');
    setEditForm({
      name: bot.name || '',
      access_password: bot.access_password || '',
      llm_provider: bot.llm_provider || 'dashscope',
      llm_model: bot.llm_model || '',
      image_tag: bot.image_tag || '',
      api_key: '',
      api_base: bot.api_base || '',
      temperature: clampTemperature(bot.temperature ?? 0.2),
      top_p: bot.top_p ?? 1,
      max_tokens: clampMaxTokens(bot.max_tokens ?? 8192),
      cpu_cores: clampCpuCores(bot.cpu_cores ?? 1),
      memory_mb: clampMemoryMb(bot.memory_mb ?? 1024),
      storage_gb: clampStorageGb(bot.storage_gb ?? 10),
      agents_md: bot.agents_md || '',
      soul_md: bot.soul_md || bot.system_prompt || '',
      user_md: bot.user_md || '',
      tools_md: bot.tools_md || '',
      identity_md: bot.identity_md || '',
    });
    setParamDraft({
      max_tokens: String(clampMaxTokens(bot.max_tokens ?? 8192)),
      cpu_cores: String(clampCpuCores(bot.cpu_cores ?? 1)),
      memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)),
      storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
    });
  }, []);
  const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
    const query = [`path=${encodeURIComponent(filePath)}`];
    if (forceDownload) query.push('download=1');
    return `/public/bots/${encodeURIComponent(selectedBotId)}/workspace/download?${query.join('&')}`;
  };
  const closeWorkspacePreview = () => {
    setWorkspacePreview(null);
    setWorkspacePreviewFullscreen(false);
  };
  const triggerWorkspaceFileDownload = (filePath: string) => {
    if (!selectedBotId) return;
    const normalized = String(filePath || '').trim();
    if (!normalized) return;
    const filename = normalized.split('/').pop() || 'workspace-file';
    const link = document.createElement('a');
    link.href = buildWorkspaceDownloadHref(normalized, true);
    link.download = filename;
    link.rel = 'noopener noreferrer';
    document.body.appendChild(link);
    link.click();
    link.remove();
  };
  const copyWorkspacePreviewUrl = async (filePath: string) => {
    const normalized = String(filePath || '').trim();
    if (!selectedBotId || !normalized) return;
    const hrefRaw = buildWorkspaceDownloadHref(normalized, false);
    const href = (() => {
      try {
        return new URL(hrefRaw, window.location.origin).href;
      } catch {
        return hrefRaw;
      }
    })();
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(href);
      } else {
        const ta = document.createElement('textarea');
        ta.value = href;
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
      }
      notify(t.urlCopied, { tone: 'success' });
    } catch {
      notify(t.urlCopyFail, { tone: 'error' });
    }
  };
  const copyWorkspacePreviewPath = async (filePath: string) => {
    const normalized = String(filePath || '').trim();
    if (!normalized) return;
    await copyTextToClipboard(
      normalized,
      isZh ? '文件路径已复制' : 'File path copied',
      isZh ? '文件路径复制失败' : 'Failed to copy file path',
    );
  };
  const copyTextToClipboard = async (textRaw: string, successMsg: string, failMsg: string) => {
    const text = String(textRaw || '');
    if (!text.trim()) return;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(text);
      } else {
        const ta = document.createElement('textarea');
        ta.value = text;
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
      }
      notify(successMsg, { tone: 'success' });
    } catch {
      notify(failMsg, { tone: 'error' });
    }
  };
  const openWorkspacePathFromChat = async (path: string) => {
    const normalized = String(path || '').trim();
    if (!normalized) return;
    const action = workspaceFileAction(normalized);
    if (action === 'download') {
      triggerWorkspaceFileDownload(normalized);
      return;
    }
    if (action === 'preview') {
      void openWorkspaceFilePreview(normalized);
      return;
    }
    try {
      await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
        params: { path: normalized },
      });
      await loadWorkspaceTree(selectedBotId, normalized);
      return;
    } catch {
      if (!isPreviewableWorkspacePath(normalized) || action === 'unsupported') {
        notify(fileNotPreviewableLabel, { tone: 'warning' });
        return;
      }
    }
  };
  const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
    const source = String(text || '');
    if (!source) return [source];
    const pattern =
      /\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],，。！？；：]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
    const nodes: ReactNode[] = [];
    let lastIndex = 0;
    let matchIndex = 0;
    let match = pattern.exec(source);
    while (match) {
      if (match.index > lastIndex) {
        nodes.push(source.slice(lastIndex, match.index));
      }

      const raw = match[0];
      const markdownPath = match[1] ? String(match[1]) : '';
      const markdownHref = match[2] ? String(match[2]) : '';
      let normalizedPath = '';
      let displayText = raw;

      if (markdownPath && markdownHref) {
        normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
        displayText = markdownPath;
      } else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
        normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
        displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
      } else if (raw.startsWith('/root/.nanobot/workspace/')) {
        normalizedPath = normalizeDashboardAttachmentPath(raw);
        displayText = raw;
      }

      if (normalizedPath) {
        nodes.push(
          <a
            key={`${keyPrefix}-ws-${matchIndex}`}
            href="#"
            onClick={(event) => {
              event.preventDefault();
              event.stopPropagation();
              void openWorkspacePathFromChat(normalizedPath);
            }}
          >
            {displayText}
          </a>,
        );
      } else {
        nodes.push(raw);
      }

      lastIndex = match.index + raw.length;
      matchIndex += 1;
      match = pattern.exec(source);
    }

    if (lastIndex < source.length) {
      nodes.push(source.slice(lastIndex));
    }
    return nodes;
  };
  const renderWorkspaceAwareChildren = (children: ReactNode, keyPrefix: string): ReactNode => {
    const list = Array.isArray(children) ? children : [children];
    const mapped = list.flatMap((child, idx) => {
      if (typeof child === 'string') {
        return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`);
      }
      return [child];
    });
    return mapped;
  };
  const markdownComponents = useMemo(
    () => ({
      a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
        const link = String(href || '').trim();
        const workspacePath = parseWorkspaceLink(link);
        if (workspacePath) {
          return (
            <a
              href="#"
              onClick={(event) => {
                event.preventDefault();
                void openWorkspacePathFromChat(workspacePath);
              }}
              {...props}
            >
              {children}
            </a>
          );
        }
        if (isExternalHttpLink(link)) {
          return (
            <a href={link} target="_blank" rel="noopener noreferrer" {...props}>
              {children}
            </a>
          );
        }
        return (
          <a href={link || '#'} {...props}>
            {children}
          </a>
        );
      },
      p: ({ children, ...props }: { children?: ReactNode }) => (
        <p {...props}>{renderWorkspaceAwareChildren(children, 'md-p')}</p>
      ),
      li: ({ children, ...props }: { children?: ReactNode }) => (
        <li {...props}>{renderWorkspaceAwareChildren(children, 'md-li')}</li>
      ),
      code: ({ children, ...props }: { children?: ReactNode }) => (
        <code {...props}>{renderWorkspaceAwareChildren(children, 'md-code')}</code>
      ),
    }),
    [fileNotPreviewableLabel, notify, selectedBotId],
  );

  const [editForm, setEditForm] = useState({
    name: '',
    access_password: '',
    llm_provider: '',
    llm_model: '',
    image_tag: '',
    api_key: '',
    api_base: '',
    temperature: 0.2,
    top_p: 1,
    max_tokens: 8192,
    cpu_cores: 1,
    memory_mb: 1024,
    storage_gb: 10,
    agents_md: '',
    soul_md: '',
    user_md: '',
    tools_md: '',
    identity_md: '',
  });
  const [paramDraft, setParamDraft] = useState({
    max_tokens: '8192',
    cpu_cores: '1',
    memory_mb: '1024',
    storage_gb: '10',
  });

  useEffect(() => {
    const ordered = Object.values(activeBots).sort((a, b) => {
      const aCreated = parseBotTimestamp(a.created_at);
      const bCreated = parseBotTimestamp(b.created_at);
      if (aCreated !== bCreated) return aCreated - bCreated;
      return String(a.id || '').localeCompare(String(b.id || ''));
    });

    ordered.forEach((bot) => {
      const id = String(bot.id || '').trim();
      if (!id) return;
      if (botOrderRef.current[id] !== undefined) return;
      botOrderRef.current[id] = nextBotOrderRef.current;
      nextBotOrderRef.current += 1;
    });

    const alive = new Set(ordered.map((bot) => String(bot.id || '').trim()).filter(Boolean));
    Object.keys(botOrderRef.current).forEach((id) => {
      if (!alive.has(id)) delete botOrderRef.current[id];
    });
  }, [activeBots]);

  const bots = useMemo(
    () =>
      Object.values(activeBots).sort((a, b) => {
        const aId = String(a.id || '').trim();
        const bId = String(b.id || '').trim();
        const aOrder = botOrderRef.current[aId] ?? Number.MAX_SAFE_INTEGER;
        const bOrder = botOrderRef.current[bId] ?? Number.MAX_SAFE_INTEGER;
        if (aOrder !== bOrder) return aOrder - bOrder;
        return aId.localeCompare(bId);
      }),
    [activeBots],
  );
  const normalizedBotListQuery = botListQuery.trim().toLowerCase();
  const filteredBots = useMemo(() => {
    if (!normalizedBotListQuery) return bots;
    return bots.filter((bot) => {
      const id = String(bot.id || '').toLowerCase();
      const name = String(bot.name || '').toLowerCase();
      return id.includes(normalizedBotListQuery) || name.includes(normalizedBotListQuery);
    });
  }, [bots, normalizedBotListQuery]);
  const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / BOT_LIST_PAGE_SIZE));
  const pagedBots = useMemo(() => {
    const page = Math.min(Math.max(1, botListPage), botListTotalPages);
    const start = (page - 1) * BOT_LIST_PAGE_SIZE;
    return filteredBots.slice(start, start + BOT_LIST_PAGE_SIZE);
  }, [filteredBots, botListPage, botListTotalPages]);
  const selectedBot = selectedBotId ? activeBots[selectedBotId] : undefined;
  const forcedBotMissing = Boolean(forcedBotId && bots.length > 0 && !activeBots[String(forcedBotId).trim()]);
  const messages = selectedBot?.messages || [];
  const events = selectedBot?.events || [];
  const isZh = locale === 'zh';
  const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
  const t = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
  const lc = isZh ? channelsZhCn : channelsEn;
  const baseImageOptions = useMemo<BaseImageOption[]>(() => {
    const readyTags = new Set(
      availableImages
        .filter((img) => String(img.status || '').toUpperCase() === 'READY')
        .map((img) => String(img.tag || '').trim())
        .filter(Boolean),
    );
    const allTags = new Set<string>();
    localDockerImages.forEach((img) => {
      const tag = String(img.tag || '').trim();
      if (tag) allTags.add(tag);
    });
    availableImages.forEach((img) => {
      const tag = String(img.tag || '').trim();
      if (tag) allTags.add(tag);
    });
    if (editForm.image_tag) {
      allTags.add(editForm.image_tag);
    }
    return Array.from(allTags)
      .sort((a, b) => a.localeCompare(b))
      .map((tag) => {
        const isReady = readyTags.has(tag);
        if (isReady) {
          return { tag, label: `${tag} · READY`, disabled: false, needsRegister: false };
        }
        const hasInDocker = localDockerImages.some((row) => String(row.tag || '').trim() === tag);
        if (hasInDocker) {
          return {
            tag,
            label: isZh ? `${tag} · 本地镜像（未登记）` : `${tag} · local image (unregistered)`,
            disabled: false,
            needsRegister: true,
          };
        }
        return {
          tag,
          label: isZh ? `${tag} · 不可用` : `${tag} · unavailable`,
          disabled: true,
          needsRegister: false,
        };
      });
  }, [availableImages, localDockerImages, editForm.image_tag, isZh]);
  const runtimeMoreLabel = isZh ? '更多' : 'More';
  const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
  const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
  const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
  const isChatEnabled = Boolean(canChat && !isSending);

  const conversation = useMemo(() => mergeConversation(messages), [messages]);
  const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
  const workspaceFiles = useMemo(
    () => workspaceEntries.filter((v) => v.type === 'file' && isPreviewableWorkspaceFile(v)),
    [workspaceEntries],
  );
  const workspacePathDisplay = workspaceCurrentPath
    ? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}`
    : '/';
  const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
  const filteredWorkspaceEntries = useMemo(() => {
    const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries;
    if (!normalizedWorkspaceQuery) return sourceEntries;
    return sourceEntries.filter((entry) => {
      const name = String(entry.name || '').toLowerCase();
      const path = String(entry.path || '').toLowerCase();
      return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery);
    });
  }, [workspaceEntries, workspaceSearchEntries, normalizedWorkspaceQuery]);
  const addableChannelTypes = useMemo(() => {
    const exists = new Set(channels.map((c) => String(c.channel_type).toLowerCase()));
    return optionalChannelTypes.filter((t) => !exists.has(t));
  }, [channels]);
  const envEntries = useMemo(
    () =>
      Object.entries(envParams || {})
        .filter(([k]) => String(k || '').trim().length > 0)
        .sort(([a], [b]) => a.localeCompare(b)),
    [envParams],
  );
  const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]);
  const lastAssistantFinalTs = useMemo(
    () =>
      [...conversation].reverse().find((m) => m.role === 'assistant' && (m.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);
  }, [latestEvent?.ts, botUpdatedAtTs, lastUserTs]);
  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;
  }, [selectedBot, lastUserTs, lastAssistantFinalTs, hasFreshRuntimeSignal]);
  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';
  }, [selectedBot, isThinking, latestEvent, hasFreshRuntimeSignal]);
  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 '-';
  }, [selectedBot, latestEvent]);
  const runtimeActionSummary = useMemo(() => {
    const full = String(runtimeAction || '').trim();
    if (!full || full === '-') return '-';
    return summarizeProgressText(full, isZh);
  }, [runtimeAction, isZh]);
  const runtimeActionHasMore = useMemo(() => {
    const full = String(runtimeAction || '').trim();
    const summary = String(runtimeActionSummary || '').trim();
    return Boolean(full && full !== '-' && summary && full !== summary);
  }, [runtimeAction, runtimeActionSummary]);
  const runtimeActionDisplay = runtimeActionHasMore ? runtimeActionSummary : runtimeAction;
  const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]);

  const hideWorkspaceHoverCard = () => setWorkspaceHoverCard(null);

  const showWorkspaceHoverCard = (node: WorkspaceNode, anchor: HTMLElement) => {
    const rect = anchor.getBoundingClientRect();
    const panelHeight = 160;
    const panelWidth = 420;
    const gap = 8;
    const viewportPadding = 8;
    const belowSpace = window.innerHeight - rect.bottom;
    const aboveSpace = rect.top;
    const above = belowSpace < panelHeight && aboveSpace > panelHeight;
    const leftRaw = rect.left + 8;
    const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding));
    const top = above ? rect.top - gap : rect.bottom + gap;
    setWorkspaceHoverCard({ node, top, left, above });
  };

  const shouldCollapseProgress = (text: string) => {
    const normalized = String(text || '').trim();
    if (!normalized) return false;
    const lines = normalized.split('\n').length;
    return lines > 6 || normalized.length > 520;
  };

  const conversationNodes = useMemo(
    () =>
      conversation.map((item, idx) => {
        const itemKey = `${item.id || item.ts}-${idx}`;
        const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
        const fullText = String(item.text || '');
        const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
        const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
        const collapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
        const expanded = Boolean(expandedProgressByKey[itemKey]);
        const displayText = isProgressBubble && !expanded ? summaryText : fullText;
        const currentDayKey = new Date(item.ts).toDateString();
        const prevDayKey = idx > 0 ? new Date(conversation[idx - 1].ts).toDateString() : '';
        const showDateDivider = idx === 0 || currentDayKey !== prevDayKey;
        return (
          <div key={itemKey}>
            {showDateDivider ? (
              <div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
                <span>{formatConversationDate(item.ts, isZh)}</span>
              </div>
            ) : null}
            <div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
              <div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
                {item.role !== 'user' && (
                  <div className="ops-avatar bot" title="Nanobot">
                    <img src={nanobotLogo} alt="Nanobot" />
                  </div>
                )}
                {item.role === 'user' ? (
                  <div className="ops-chat-hover-actions ops-chat-hover-actions-user">
                    <LucentIconButton
                      className="ops-chat-inline-action"
                      onClick={() => editUserPrompt(item.text)}
                      tooltip={t.editPrompt}
                      aria-label={t.editPrompt}
                    >
                      <Pencil size={13} />
                    </LucentIconButton>
                    <LucentIconButton
                      className="ops-chat-inline-action"
                      onClick={() => void copyUserPrompt(item.text)}
                      tooltip={t.copyPrompt}
                      aria-label={t.copyPrompt}
                    >
                      <Copy size={13} />
                    </LucentIconButton>
                  </div>
                ) : null}

                <div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
                  <div className="ops-chat-meta">
                    <span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
                    <div className="ops-chat-meta-right">
                      <span className="mono">{formatClock(item.ts)}</span>
                      {collapsible ? (
                        <LucentIconButton
                          className="ops-chat-expand-icon-btn"
                          onClick={() =>
                            setExpandedProgressByKey((prev) => ({
                              ...prev,
                              [itemKey]: !prev[itemKey],
                            }))
                          }
                          tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
                          aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
                        >
                          {expanded ? '×' : '…'}
                        </LucentIconButton>
                      ) : null}
                    </div>
                  </div>
                  <div className={`ops-chat-text ${collapsible && !expanded ? 'is-collapsed' : ''}`}>
                    {item.text ? (
                      item.role === 'user' ? (
                        <>
                          {item.quoted_reply ? (
                            <div className="ops-user-quoted-reply">
                              <div className="ops-user-quoted-label">{t.quotedReplyLabel}</div>
                              <div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
                            </div>
                          ) : null}
                          <div className="whitespace-pre-wrap">{normalizeUserMessageText(item.text)}</div>
                        </>
                      ) : (
                        <ReactMarkdown
                          remarkPlugins={[remarkGfm]}
                          rehypePlugins={[rehypeRaw, rehypeSanitize]}
                          components={markdownComponents}
                        >
                          {decorateWorkspacePathsForMarkdown(displayText)}
                        </ReactMarkdown>
                      )
                    ) : null}
                    {(item.attachments || []).length > 0 ? (
                      <div className="ops-chat-attachments">
                        {(item.attachments || []).map((rawPath) => {
                          const filePath = normalizeDashboardAttachmentPath(rawPath);
                          const fileAction = workspaceFileAction(filePath);
                          const filename = filePath.split('/').pop() || filePath;
                          return (
                            <a
                              key={`${item.ts}-${filePath}`}
                              className="ops-attach-link mono"
                                href="#"
                                onClick={(event) => {
                                  event.preventDefault();
                                  void openWorkspacePathFromChat(filePath);
                                }}
                              title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable}
                            >
                              {fileAction === 'download' ? (
                                <Download size={12} className="ops-attach-link-icon" />
                              ) : fileAction === 'preview' ? (
                                <Eye size={12} className="ops-attach-link-icon" />
                              ) : (
                                <FileText size={12} className="ops-attach-link-icon" />
                              )}
                              <span className="ops-attach-link-name">{filename}</span>
                            </a>
                          );
                        })}
                      </div>
                    ) : null}
                    {item.role === 'assistant' && !isProgressBubble ? (
                      <div className="ops-chat-reply-actions">
                        <LucentIconButton
                          className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
                          onClick={() => void submitAssistantFeedback(item, 'up')}
                          disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
                          tooltip={t.goodReply}
                          aria-label={t.goodReply}
                        >
                          <ThumbsUp size={13} />
                        </LucentIconButton>
                        <LucentIconButton
                          className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
                          onClick={() => void submitAssistantFeedback(item, 'down')}
                          disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
                          tooltip={t.badReply}
                          aria-label={t.badReply}
                        >
                          <ThumbsDown size={13} />
                        </LucentIconButton>
                        <LucentIconButton
                          className="ops-chat-inline-action"
                          onClick={() => quoteAssistantReply(item)}
                          tooltip={t.quoteReply}
                          aria-label={t.quoteReply}
                        >
                          <Reply size={13} />
                        </LucentIconButton>
                        <LucentIconButton
                          className="ops-chat-inline-action"
                          onClick={() => void copyAssistantReply(item.text)}
                          tooltip={t.copyReply}
                          aria-label={t.copyReply}
                        >
                          <Copy size={13} />
                        </LucentIconButton>
                      </div>
                    ) : null}
                  </div>
                </div>
              </div>
              {item.role === 'user' && (
                <div className="ops-avatar user" title={t.user}>
                  <UserRound size={18} />
                </div>
              )}
            </div>
          </div>
        );
      }),
    [
      conversation,
      expandedProgressByKey,
      feedbackSavingByMessageId,
      isZh,
      selectedBotId,
      t.badReply,
      t.copyPrompt,
      t.copyReply,
      t.quoteReply,
      t.quotedReplyLabel,
      t.goodReply,
      t.user,
      t.you,
    ],
  );

  useEffect(() => {
    setBotListPage(1);
  }, [normalizedBotListQuery]);

  useEffect(() => {
    setBotListPage((prev) => Math.min(Math.max(prev, 1), botListTotalPages));
  }, [botListTotalPages]);

  useEffect(() => {
    const forced = String(forcedBotId || '').trim();
    if (forced) {
      if (activeBots[forced]) {
        if (selectedBotId !== forced) setSelectedBotId(forced);
      } else if (selectedBotId) {
        setSelectedBotId('');
      }
      return;
    }
    if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
    if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
  }, [bots, selectedBotId, activeBots, forcedBotId]);

  useEffect(() => {
    setComposerDraftHydrated(false);
    if (!selectedBotId) {
      setCommand('');
      setPendingAttachments([]);
      setComposerDraftHydrated(true);
      return;
    }
    const draft = loadComposerDraft(selectedBotId);
    setCommand(draft?.command || '');
    setPendingAttachments(draft?.attachments || []);
    setComposerDraftHydrated(true);
  }, [selectedBotId]);

  useEffect(() => {
    if (!selectedBotId || !composerDraftHydrated) return;
    persistComposerDraft(selectedBotId, command, pendingAttachments);
  }, [selectedBotId, composerDraftHydrated, command, pendingAttachments]);

  useEffect(() => {
    const hasDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
    if (!hasDraft && !isUploadingAttachments) return;
    const onBeforeUnload = (event: BeforeUnloadEvent) => {
      event.preventDefault();
      event.returnValue = '';
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [command, pendingAttachments.length, quotedReply, isUploadingAttachments]);

  useEffect(() => {
    chatBottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
  }, [selectedBotId, conversation.length]);

  useEffect(() => {
    setQuotedReply(null);
  }, [selectedBotId]);

  useEffect(() => {
    const onPointerDown = (event: MouseEvent) => {
      if (!runtimeMenuRef.current) return;
      if (!runtimeMenuRef.current.contains(event.target as Node)) {
        setRuntimeMenuOpen(false);
      }
    };
    document.addEventListener('mousedown', onPointerDown);
    return () => document.removeEventListener('mousedown', onPointerDown);
  }, []);

  useEffect(() => {
    setRuntimeMenuOpen(false);
  }, [selectedBotId]);

  useEffect(() => {
    setExpandedProgressByKey({});
    setShowRuntimeActionModal(false);
    setWorkspaceHoverCard(null);
  }, [selectedBotId]);

  useEffect(() => {
    if (!selectedBotId) return;
    let alive = true;
    const loadBotDetail = async () => {
      try {
        const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
        if (alive) mergeBot(res.data);
      } catch (error) {
        console.error(`Failed to fetch bot detail for ${selectedBotId}`, error);
      }
    };
    void loadBotDetail();
    return () => {
      alive = false;
    };
  }, [selectedBotId, mergeBot]);

  useEffect(() => {
    if (!workspaceHoverCard) return;
    const close = () => setWorkspaceHoverCard(null);
    window.addEventListener('scroll', close, true);
    window.addEventListener('resize', close);
    return () => {
      window.removeEventListener('scroll', close, true);
      window.removeEventListener('resize', close);
    };
  }, [workspaceHoverCard]);

  useEffect(() => {
    let alive = true;
    const loadSystemDefaults = async () => {
      try {
        const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
        const configured = Number(res.data?.limits?.upload_max_mb);
        if (!Number.isFinite(configured) || configured <= 0 || !alive) return;
        setUploadMaxMb(Math.max(1, Math.floor(configured)));
      } catch {
        // keep default limit
      }
    };
    void loadSystemDefaults();
    return () => {
      alive = false;
    };
  }, []);

  useEffect(() => {
    if (!compactMode) {
      setIsCompactMobile(false);
      setCompactPanelTab('chat');
      return;
    }
    const media = window.matchMedia('(max-width: 980px)');
    const apply = () => setIsCompactMobile(media.matches);
    apply();
    media.addEventListener('change', apply);
    return () => media.removeEventListener('change', apply);
  }, [compactMode]);

  useEffect(() => {
    if (!selectedBotId) return;
    if (showBaseModal || showParamModal || showAgentModal) return;
    applyEditFormFromBot(selectedBot);
  }, [
    selectedBotId,
    selectedBot?.id,
    selectedBot?.updated_at,
    showBaseModal,
    showParamModal,
    showAgentModal,
    applyEditFormFromBot,
  ]);

  useEffect(() => {
    if (!selectedBotId || !selectedBot) {
      setGlobalDelivery({ sendProgress: false, sendToolHints: false });
      return;
    }
    setGlobalDelivery({
      sendProgress: Boolean(selectedBot.send_progress),
      sendToolHints: Boolean(selectedBot.send_tool_hints),
    });
  }, [selectedBotId, selectedBot?.send_progress, selectedBot?.send_tool_hints]);

  const loadImageOptions = async () => {
    const [imagesRes, dockerImagesRes] = await Promise.allSettled([
      axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`),
      axios.get<DockerImage[]>(`${APP_ENDPOINTS.apiBase}/docker-images`),
    ]);
    if (imagesRes.status === 'fulfilled') {
      setAvailableImages(Array.isArray(imagesRes.value.data) ? imagesRes.value.data : []);
    } else {
      setAvailableImages([]);
    }
    if (dockerImagesRes.status === 'fulfilled') {
      setLocalDockerImages(Array.isArray(dockerImagesRes.value.data) ? dockerImagesRes.value.data : []);
    } else {
      setLocalDockerImages([]);
    }
  };

  const refresh = async () => {
    const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
    setBots(botsRes.data);
    await loadImageOptions();
  };

  const ensureSelectedBotDetail = useCallback(async () => {
    if (!selectedBotId) return selectedBot;
    try {
      const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
      mergeBot(res.data);
      return res.data;
    } catch {
      return selectedBot;
    }
  }, [selectedBotId, selectedBot, mergeBot]);

  const loadResourceSnapshot = async (botId: string) => {
    if (!botId) return;
    setResourceLoading(true);
    setResourceError('');
    try {
      const res = await axios.get<BotResourceSnapshot>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/resources`);
      setResourceSnapshot(res.data);
    } catch (error: any) {
      const msg = error?.response?.data?.detail || (isZh ? '读取资源监控失败。' : 'Failed to load resource metrics.');
      setResourceError(String(msg));
    } finally {
      setResourceLoading(false);
    }
  };

  const openResourceMonitor = (botId: string) => {
    setResourceBotId(botId);
    setShowResourceModal(true);
    void loadResourceSnapshot(botId);
  };

  useEffect(() => {
    void loadImageOptions();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    if (!showBaseModal) return;
    void loadImageOptions();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showBaseModal]);

  useEffect(() => {
    if (!showResourceModal || !resourceBotId) return;
    let stopped = false;
    const tick = async () => {
      if (stopped) return;
      await loadResourceSnapshot(resourceBotId);
    };
    const timer = window.setInterval(() => {
      void tick();
    }, 2000);
    return () => {
      stopped = true;
      window.clearInterval(timer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [showResourceModal, resourceBotId]);

  const openWorkspaceFilePreview = async (path: string) => {
    if (!selectedBotId || !path) return;
    const normalizedPath = String(path || '').trim();
    setWorkspacePreviewFullscreen(false);
    if (isPdfPath(normalizedPath) || isOfficePath(normalizedPath)) {
      triggerWorkspaceFileDownload(normalizedPath);
      return;
    }
    if (isImagePath(normalizedPath)) {
      const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
      setWorkspacePreview({
        path: normalizedPath,
        content: '',
        truncated: false,
        ext: fileExt ? `.${fileExt}` : '',
        isMarkdown: false,
        isImage: true,
        isHtml: false,
        isVideo: false,
        isAudio: false,
      });
      return;
    }
    if (isHtmlPath(normalizedPath)) {
      const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
      setWorkspacePreview({
        path: normalizedPath,
        content: '',
        truncated: false,
        ext: fileExt ? `.${fileExt}` : '',
        isMarkdown: false,
        isImage: false,
        isHtml: true,
        isVideo: false,
        isAudio: false,
      });
      return;
    }
    if (isVideoPath(normalizedPath)) {
      const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
      setWorkspacePreview({
        path: normalizedPath,
        content: '',
        truncated: false,
        ext: fileExt ? `.${fileExt}` : '',
        isMarkdown: false,
        isImage: false,
        isHtml: false,
        isVideo: true,
        isAudio: false,
      });
      return;
    }
    if (isAudioPath(normalizedPath)) {
      const fileExt = (normalizedPath.split('.').pop() || '').toLowerCase();
      setWorkspacePreview({
        path: normalizedPath,
        content: '',
        truncated: false,
        ext: fileExt ? `.${fileExt}` : '',
        isMarkdown: false,
        isImage: false,
        isHtml: false,
        isVideo: false,
        isAudio: true,
      });
      return;
    }
    setWorkspaceFileLoading(true);
    try {
      const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
        params: { path, max_bytes: 400000 },
      });
      const filePath = res.data.path || path;
      const textExt = (filePath.split('.').pop() || '').toLowerCase();
      let content = res.data.content || '';
      if (textExt === 'json') {
        try {
          content = JSON.stringify(JSON.parse(content), null, 2);
        } catch {
          // Keep original content when JSON is not strictly parseable.
        }
      }
      setWorkspacePreview({
        path: filePath,
        content,
        truncated: Boolean(res.data.truncated),
        ext: textExt ? `.${textExt}` : '',
        isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
        isImage: false,
        isHtml: false,
        isVideo: false,
        isAudio: false,
      });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.fileReadFail;
      notify(msg, { tone: 'error' });
    } finally {
      setWorkspaceFileLoading(false);
    }
  };

  const loadWorkspaceTree = async (botId: string, path: string = '') => {
    if (!botId) return;
    setWorkspaceLoading(true);
    setWorkspaceError('');
    try {
      const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
        params: { path },
      });
      const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
      setWorkspaceEntries(entries);
      setWorkspaceSearchEntries([]);
      setWorkspaceCurrentPath(res.data?.cwd || '');
      setWorkspaceParentPath(res.data?.parent ?? null);
    } catch (error: any) {
      setWorkspaceEntries([]);
      setWorkspaceSearchEntries([]);
      setWorkspaceCurrentPath('');
      setWorkspaceParentPath(null);
      setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
    } finally {
      setWorkspaceLoading(false);
    }
  };

  const loadWorkspaceSearchEntries = async (botId: string, path: string = '') => {
    if (!botId) return;
    const q = String(workspaceQuery || '').trim();
    if (!q) {
      setWorkspaceSearchEntries([]);
      setWorkspaceSearchLoading(false);
      return;
    }
    setWorkspaceSearchLoading(true);
    try {
      const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
        params: { path, recursive: true },
      });
      const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
      setWorkspaceSearchEntries(entries);
    } catch {
      setWorkspaceSearchEntries([]);
    } finally {
      setWorkspaceSearchLoading(false);
    }
  };

  const loadChannels = async (botId: string) => {
    if (!botId) return;
    const res = await axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
    setChannels(Array.isArray(res.data) ? res.data : []);
  };

  const loadBotSkills = async (botId: string) => {
    if (!botId) return;
    const res = await axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`);
    setBotSkills(Array.isArray(res.data) ? res.data : []);
  };

  const loadBotEnvParams = async (botId: string) => {
    if (!botId) return;
    try {
      const res = await axios.get<{ env_params?: Record<string, string> }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`);
      const rows = res.data?.env_params && typeof res.data.env_params === 'object' ? res.data.env_params : {};
      const next: BotEnvParams = {};
      Object.entries(rows).forEach(([k, v]) => {
        const key = String(k || '').trim().toUpperCase();
        if (!key) return;
        next[key] = String(v ?? '');
      });
      setEnvParams(next);
    } catch {
      setEnvParams({});
    }
  };

  const saveBotEnvParams = async () => {
    if (!selectedBot) return;
    try {
      await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/env-params`, { env_params: envParams });
      setShowEnvParamsModal(false);
      notify(t.envParamsSaved, { tone: 'success' });
    } catch (error: any) {
      notify(error?.response?.data?.detail || t.envParamsSaveFail, { tone: 'error' });
    }
  };

  const upsertEnvParam = (key: string, value: string) => {
    const normalized = String(key || '').trim().toUpperCase();
    if (!normalized) return;
    setEnvParams((prev) => ({ ...(prev || {}), [normalized]: String(value ?? '') }));
  };

  const removeEnvParam = (key: string) => {
    const normalized = String(key || '').trim().toUpperCase();
    if (!normalized) return;
    setEnvParams((prev) => {
      const next = { ...(prev || {}) };
      delete next[normalized];
      return next;
    });
  };

  const removeBotSkill = async (skill: WorkspaceSkillOption) => {
    if (!selectedBot) return;
    const ok = await confirm({
      title: t.removeSkill,
      message: t.toolsRemoveConfirm(skill.name || skill.id),
      tone: 'warning',
    });
    if (!ok) return;
    try {
      await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/${encodeURIComponent(skill.id)}`);
      await loadBotSkills(selectedBot.id);
    } catch (error: any) {
      notify(error?.response?.data?.detail || t.toolsRemoveFail, { tone: 'error' });
    }
  };

  const triggerSkillZipUpload = () => {
    if (!selectedBot || isSkillUploading) return;
    skillZipPickerRef.current?.click();
  };

  const onPickSkillZip = async (event: ChangeEvent<HTMLInputElement>) => {
    if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
    const file = event.target.files[0];
    const filename = String(file?.name || '').toLowerCase();
    if (!filename.endsWith('.zip')) {
      notify(t.invalidZipFile, { tone: 'warning' });
      event.target.value = '';
      return;
    }
    const formData = new FormData();
    formData.append('file', file);
    setIsSkillUploading(true);
    try {
      const res = await axios.post<SkillUploadResponse>(
        `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/skills/upload`,
        formData,
      );
      const nextSkills = Array.isArray(res.data?.skills) ? res.data.skills : [];
      setBotSkills(nextSkills);
    } catch (error: any) {
      notify(error?.response?.data?.detail || t.toolsAddFail, { tone: 'error' });
    } finally {
      setIsSkillUploading(false);
      event.target.value = '';
    }
  };

  const loadCronJobs = async (botId: string) => {
    if (!botId) return;
    setCronLoading(true);
    try {
      const res = await axios.get<CronJobsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, {
        params: { include_disabled: true },
      });
      setCronJobs(Array.isArray(res.data?.jobs) ? res.data.jobs : []);
    } catch {
      setCronJobs([]);
    } finally {
      setCronLoading(false);
    }
  };

  const stopCronJob = async (jobId: string) => {
    if (!selectedBot || !jobId) return;
    setCronActionJobId(jobId);
    try {
      await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}/stop`);
      await loadCronJobs(selectedBot.id);
    } catch (error: any) {
      notify(error?.response?.data?.detail || t.cronStopFail, { tone: 'error' });
    } finally {
      setCronActionJobId('');
    }
  };

  const deleteCronJob = async (jobId: string) => {
    if (!selectedBot || !jobId) return;
    const ok = await confirm({
      title: t.cronDelete,
      message: t.cronDeleteConfirm(jobId),
      tone: 'warning',
    });
    if (!ok) return;
    setCronActionJobId(jobId);
    try {
      await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/cron/jobs/${jobId}`);
      await loadCronJobs(selectedBot.id);
    } catch (error: any) {
      notify(error?.response?.data?.detail || t.cronDeleteFail, { tone: 'error' });
    } finally {
      setCronActionJobId('');
    }
  };

  const updateChannelLocal = (index: number, patch: Partial<BotChannel>) => {
    setChannels((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c)));
  };

  const saveChannel = async (channel: BotChannel) => {
    if (!selectedBot) return;
    setIsSavingChannel(true);
    try {
      await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`, {
        channel_type: channel.channel_type,
        external_app_id: channel.external_app_id,
        app_secret: channel.app_secret,
        internal_port: Number(channel.internal_port),
        is_active: channel.is_active,
        extra_config: sanitizeChannelExtra(String(channel.channel_type), channel.extra_config || {}),
      });
      await loadChannels(selectedBot.id);
      notify(t.channelSaved, { tone: 'success' });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.channelSaveFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsSavingChannel(false);
    }
  };

  const addChannel = async () => {
    if (!selectedBot || !addableChannelTypes.includes(newChannelType)) return;
    setIsSavingChannel(true);
    try {
      await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels`, {
        channel_type: newChannelType,
        is_active: true,
        external_app_id: '',
        app_secret: '',
        internal_port: 8080,
        extra_config: {},
      });
      await loadChannels(selectedBot.id);
      const rest = addableChannelTypes.filter((t) => t !== newChannelType);
      if (rest.length > 0) setNewChannelType(rest[0]);
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.channelAddFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsSavingChannel(false);
    }
  };

  const removeChannel = async (channel: BotChannel) => {
    if (!selectedBot || channel.channel_type === 'dashboard') return;
    const ok = await confirm({
      title: t.channels,
      message: t.channelDeleteConfirm(channel.channel_type),
      tone: 'warning',
    });
    if (!ok) return;
    setIsSavingChannel(true);
    try {
      await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/channels/${channel.id}`);
      await loadChannels(selectedBot.id);
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.channelDeleteFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsSavingChannel(false);
    }
  };

  const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard';
  const sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
    const type = String(channelType || '').toLowerCase();
    if (type === 'dashboard') return extra || {};
    const next = { ...(extra || {}) };
    delete next.sendProgress;
    delete next.sendToolHints;
    return next;
  };
  const updateGlobalDeliveryFlag = (key: 'sendProgress' | 'sendToolHints', value: boolean) => {
    setGlobalDelivery((prev) => ({ ...prev, [key]: value }));
  };
  const saveGlobalDelivery = async () => {
    if (!selectedBot) return;
    setIsSavingGlobalDelivery(true);
    try {
      await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}`, {
        send_progress: Boolean(globalDelivery.sendProgress),
        send_tool_hints: Boolean(globalDelivery.sendToolHints),
      });
      if (selectedBot.docker_status === 'RUNNING') {
        await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/stop`);
        await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/start`);
      }
      await refresh();
      notify(t.channelSaved, { tone: 'success' });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.channelSaveFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsSavingGlobalDelivery(false);
    }
  };

  const renderChannelFields = (channel: BotChannel, idx: number) => {
    const ctype = String(channel.channel_type).toLowerCase();
    if (ctype === 'telegram') {
      return (
        <>
          <input className="input" type="password" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
          <input
            className="input"
            placeholder={lc.proxy}
            value={String((channel.extra_config || {}).proxy || '')}
            onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
          />
          <label className="field-label">
            <input
              type="checkbox"
              checked={Boolean((channel.extra_config || {}).replyToMessage)}
              onChange={(e) =>
                updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
              }
              style={{ marginRight: 6 }}
            />
            {lc.replyToMessage}
          </label>
        </>
      );
    }

    if (ctype === 'feishu') {
      return (
        <>
          <input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
          <input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
          <input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} />
          <input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => updateChannelLocal(idx, { extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} />
        </>
      );
    }

    if (ctype === 'dingtalk') {
      return (
        <>
          <input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
          <input className="input" type="password" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
        </>
      );
    }

    if (ctype === 'slack') {
      return (
        <>
          <input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
          <input className="input" type="password" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
        </>
      );
    }

    if (ctype === 'qq') {
      return (
        <>
          <input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => updateChannelLocal(idx, { external_app_id: e.target.value })} />
          <input className="input" type="password" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => updateChannelLocal(idx, { app_secret: e.target.value })} />
        </>
      );
    }

    return null;
  };

  const stopBot = async (id: string, status: string) => {
    if (status !== 'RUNNING') return;
    setOperatingBotId(id);
    setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
    try {
      await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
      updateBotStatus(id, 'STOPPED');
      await refresh();
    } catch {
      notify(t.stopFail, { tone: 'error' });
    } finally {
      setOperatingBotId(null);
      setControlStateByBot((prev) => {
        const next = { ...prev };
        delete next[id];
        return next;
      });
    }
  };

  const startBot = async (id: string, status: string) => {
    if (status === 'RUNNING') return;
    setOperatingBotId(id);
    setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
    try {
      await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
      updateBotStatus(id, 'RUNNING');
      await refresh();
    } catch {
      notify(t.startFail, { tone: 'error' });
    } finally {
      setOperatingBotId(null);
      setControlStateByBot((prev) => {
        const next = { ...prev };
        delete next[id];
        return next;
      });
    }
  };

  const restartBot = async (id: string, status: string) => {
    const normalized = String(status || '').toUpperCase();
    const ok = await confirm({
      title: t.restart,
      message: t.restartConfirm(id),
      tone: 'warning',
    });
    if (!ok) return;
    setOperatingBotId(id);
    try {
      if (normalized === 'RUNNING') {
        setControlStateByBot((prev) => ({ ...prev, [id]: 'stopping' }));
        await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/stop`);
        updateBotStatus(id, 'STOPPED');
      }
      setControlStateByBot((prev) => ({ ...prev, [id]: 'starting' }));
      await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/start`);
      updateBotStatus(id, 'RUNNING');
      await refresh();
    } catch {
      notify(t.restartFail, { tone: 'error' });
    } finally {
      setOperatingBotId(null);
      setControlStateByBot((prev) => {
        const next = { ...prev };
        delete next[id];
        return next;
      });
    }
  };

  const send = async () => {
    if (!selectedBot || !canChat || isSending) return;
    if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
    const text = normalizeUserMessageText(command);
    const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
    const quoteBlock = quoteText ? `[Quoted Reply]\n${quoteText}\n[/Quoted Reply]\n` : '';
    const payloadCore = text || (pendingAttachments.length > 0 ? t.attachmentMessage : '') || (quoteText ? t.quoteOnlyMessage : '');
    const payloadText = `${quoteBlock}${payloadCore}`.trim();
    if (!payloadText && pendingAttachments.length === 0) return;

    try {
      setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
      const res = await axios.post(
        `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
        { command: payloadText, attachments: pendingAttachments },
        { timeout: 12000 },
      );
      if (!res.data?.success) {
        throw new Error(t.backendDeliverFail);
      }
      setCommand('');
      setPendingAttachments([]);
      setQuotedReply(null);
    } catch (error: any) {
      const msg = error?.response?.data?.detail || error?.message || t.sendFail;
      addBotMessage(selectedBot.id, {
        role: 'assistant',
        text: t.sendFailMsg(msg),
        ts: Date.now(),
      });
      notify(msg, { tone: 'error' });
    } finally {
      setSendingByBot((prev) => {
        const next = { ...prev };
        delete next[selectedBot.id];
        return next;
      });
    }
  };

  const interruptExecution = async () => {
    if (!selectedBot || !canChat) return;
    if (interruptingByBot[selectedBot.id]) return;
    try {
      setInterruptingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
      const res = await axios.post(
        `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
        { command: '/stop' },
        { timeout: 12000 },
      );
      if (!res.data?.success) {
        throw new Error(t.backendDeliverFail);
      }
      notify(t.interruptSent, { tone: 'success' });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || error?.message || t.sendFail;
      notify(msg, { tone: 'error' });
    } finally {
      setInterruptingByBot((prev) => {
        const next = { ...prev };
        delete next[selectedBot.id];
        return next;
      });
    }
  };

  const copyUserPrompt = async (text: string) => {
    await copyTextToClipboard(normalizeUserMessageText(text), t.copyPromptDone, t.copyPromptFail);
  };

  const editUserPrompt = (text: string) => {
    const normalized = normalizeUserMessageText(text);
    if (!normalized) return;
    setCommand(normalized);
    composerTextareaRef.current?.focus();
    if (composerTextareaRef.current) {
      const caret = normalized.length;
      window.requestAnimationFrame(() => {
        composerTextareaRef.current?.setSelectionRange(caret, caret);
      });
    }
    notify(t.editPromptDone, { tone: 'success' });
  };

  const copyAssistantReply = async (text: string) => {
    await copyTextToClipboard(normalizeAssistantMessageText(text), t.copyReplyDone, t.copyReplyFail);
  };

  const quoteAssistantReply = (message: ChatMessage) => {
    const content = normalizeAssistantMessageText(message.text);
    if (!content) return;
    setQuotedReply((prev) => {
      if (prev && prev.ts === message.ts && normalizeAssistantMessageText(prev.text) === content) {
        return null;
      }
      return { id: message.id, ts: message.ts, text: content };
    });
  };

  const fetchBotMessages = async (botId: string): Promise<ChatMessage[]> => {
    const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
      params: { limit: 300 },
    });
    const rows = Array.isArray(res.data) ? res.data : [];
    return rows
      .map((row) => {
        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;
      })
      .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
      .slice(-300);
  };

  const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
    if (!selectedBotId) {
      notify(t.feedbackMessagePending, { tone: 'warning' });
      return;
    }
    let targetMessageId = message.id;
    if (!targetMessageId) {
      try {
        const latest = await fetchBotMessages(selectedBotId);
        setBotMessages(selectedBotId, latest);
        const normalizedTarget = normalizeAssistantMessageText(message.text);
        const matched = latest
          .filter((m) => m.role === 'assistant' && m.id)
          .map((m) => ({ m, diff: Math.abs((m.ts || 0) - (message.ts || 0)) }))
          .filter(({ m, diff }) => normalizeAssistantMessageText(m.text) === normalizedTarget && diff <= 10 * 60 * 1000)
          .sort((a, b) => a.diff - b.diff)[0]?.m;
        if (matched?.id) {
          targetMessageId = matched.id;
        }
      } catch {
        // ignore and fallback to warning below
      }
    }
    if (!targetMessageId) {
      notify(t.feedbackMessagePending, { tone: 'warning' });
      return;
    }
    if (feedbackSavingByMessageId[targetMessageId]) return;
    const nextFeedback: 'up' | 'down' | null = message.feedback === feedback ? null : feedback;
    setFeedbackSavingByMessageId((prev) => ({ ...prev, [targetMessageId]: true }));
    try {
      await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/messages/${targetMessageId}/feedback`, { feedback: nextFeedback });
      setBotMessageFeedback(selectedBotId, targetMessageId, nextFeedback);
      if (nextFeedback === null) {
        notify(t.feedbackCleared, { tone: 'success' });
      } else {
        notify(nextFeedback === 'up' ? t.feedbackUpSaved : t.feedbackDownSaved, { tone: 'success' });
      }
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.feedbackSaveFail;
      notify(msg, { tone: 'error' });
    } finally {
      setFeedbackSavingByMessageId((prev) => {
        const next = { ...prev };
        delete next[targetMessageId];
        return next;
      });
    }
  };

  const onComposerKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    const native = e.nativeEvent as unknown as { isComposing?: boolean; keyCode?: number };
    if (native.isComposing || native.keyCode === 229) return;
    const isEnter = e.key === 'Enter' || e.key === 'NumpadEnter';
    if (!isEnter || e.shiftKey) return;
    e.preventDefault();
    void send();
  };

  const triggerPickAttachments = () => {
    if (!selectedBot || !canChat || isUploadingAttachments) return;
    filePickerRef.current?.click();
  };

  const onVoiceInput = () => {
    notify(t.voiceUnavailable, { tone: 'warning' });
  };

  const onPickAttachments = async (event: ChangeEvent<HTMLInputElement>) => {
    if (!selectedBot || !event.target.files || event.target.files.length === 0) return;
    const files = Array.from(event.target.files);
    const maxBytes = uploadMaxMb * 1024 * 1024;
    const tooLarge = files.filter((f) => Number(f.size) > maxBytes);
    if (tooLarge.length > 0) {
      const names = tooLarge.map((f) => String(f.name || '').trim() || 'unknown').slice(0, 3).join(', ');
      notify(t.uploadTooLarge(names, uploadMaxMb), { tone: 'warning' });
      event.target.value = '';
      return;
    }
    const mediaFiles: File[] = [];
    const normalFiles: File[] = [];
    files.forEach((file) => {
      if (isMediaUploadFile(file)) {
        mediaFiles.push(file);
      } else {
        normalFiles.push(file);
      }
    });

    const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
    let uploadedBytes = 0;
    const uploadedPaths: string[] = [];

    const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
      if (batchFiles.length === 0) return;
      const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
      const formData = new FormData();
      batchFiles.forEach((file) => formData.append('files', file));
      const res = await axios.post<WorkspaceUploadResponse>(
        `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/workspace/upload`,
        formData,
        {
          params: { path },
          onUploadProgress: (progressEvent) => {
            const loaded = Number(progressEvent.loaded || 0);
            if (!Number.isFinite(loaded) || loaded < 0) {
              setAttachmentUploadPercent(null);
              return;
            }
            if (totalBytes <= 0) {
              setAttachmentUploadPercent(null);
              return;
            }
            const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded));
            const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100)));
            setAttachmentUploadPercent(pct);
          },
        },
      );
      const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((v) => v.path));
      uploadedPaths.push(...uploaded);
      uploadedBytes += batchBytes;
      if (totalBytes > 0) {
        const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)));
        setAttachmentUploadPercent(pct);
      }
    };

    setIsUploadingAttachments(true);
    setAttachmentUploadPercent(0);
    try {
      await uploadBatch(mediaFiles, 'media');
      await uploadBatch(normalFiles, 'uploads');
      if (uploadedPaths.length > 0) {
        setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
        await loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
      }
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.uploadFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsUploadingAttachments(false);
      setAttachmentUploadPercent(null);
      event.target.value = '';
    }
  };

  const onBaseProviderChange = (provider: string) => {
    const preset = providerPresets[provider];
    setEditForm((p) => ({
      ...p,
      llm_provider: provider,
      llm_model: preset?.model || p.llm_model,
      api_base: preset?.apiBase ?? p.api_base,
    }));
    setProviderTestResult('');
  };

  const testProviderConnection = async () => {
    if (!editForm.llm_provider || !editForm.llm_model || !editForm.api_key.trim()) {
      notify(t.providerRequired, { tone: 'warning' });
      return;
    }
    setIsTestingProvider(true);
    setProviderTestResult('');
    try {
      const res = await axios.post(`${APP_ENDPOINTS.apiBase}/providers/test`, {
        provider: editForm.llm_provider,
        model: editForm.llm_model,
        api_key: editForm.api_key.trim(),
        api_base: editForm.api_base || undefined,
      });
      if (res.data?.ok) {
        const preview = (res.data.models_preview || []).slice(0, 3).join(', ');
        setProviderTestResult(t.connOk(preview));
      } else {
        setProviderTestResult(t.connFail(res.data?.detail || 'unknown error'));
      }
    } catch (error: any) {
      const msg = error?.response?.data?.detail || error?.message || 'request failed';
      setProviderTestResult(t.connFail(msg));
    } finally {
      setIsTestingProvider(false);
    }
  };

  useEffect(() => {
    if (!selectedBotId) {
      setWorkspaceEntries([]);
      setWorkspaceCurrentPath('');
      setWorkspaceParentPath(null);
      setWorkspaceError('');
      setChannels([]);
      setPendingAttachments([]);
      setCronJobs([]);
      setBotSkills([]);
      setEnvParams({});
      return;
    }
    let cancelled = false;
    const loadAll = async () => {
      try {
        if (cancelled) return;
        await Promise.all([
          loadWorkspaceTree(selectedBotId, ''),
          loadCronJobs(selectedBotId),
          loadBotSkills(selectedBotId),
          loadBotEnvParams(selectedBotId),
        ]);
      } catch (error: any) {
        const detail = String(error?.response?.data?.detail || '').trim();
        if (!cancelled && detail) {
          notify(detail, { tone: 'error' });
        }
      }
    };
    void loadAll();
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectedBotId]);

  useEffect(() => {
    if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
    let stopped = false;

    const tick = async () => {
      if (stopped) return;
      await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
    };

    void tick();
    const timer = window.setInterval(() => {
      void tick();
    }, 2000);

    return () => {
      stopped = true;
      window.clearInterval(timer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [workspaceAutoRefresh, selectedBotId, selectedBot?.docker_status, workspaceCurrentPath]);

  useEffect(() => {
    setWorkspaceQuery('');
  }, [selectedBotId, workspaceCurrentPath]);

  useEffect(() => {
    if (!selectedBotId) {
      setWorkspaceSearchEntries([]);
      setWorkspaceSearchLoading(false);
      return;
    }
    if (!workspaceQuery.trim()) {
      setWorkspaceSearchEntries([]);
      setWorkspaceSearchLoading(false);
      return;
    }
    void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectedBotId, workspaceCurrentPath, workspaceQuery]);

  const saveBot = async (mode: 'params' | 'agent' | 'base') => {
    const targetBotId = String(selectedBot?.id || selectedBotId || '').trim();
    if (!targetBotId) {
      notify(isZh ? '未选中 Bot，无法保存。' : 'No bot selected.', { tone: 'warning' });
      return;
    }
    setIsSaving(true);
    try {
      const payload: Record<string, string | number> = {};
      if (mode === 'base') {
        payload.name = editForm.name;
        payload.access_password = editForm.access_password;
        payload.image_tag = editForm.image_tag;
        const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag);
        if (selectedImageOption?.disabled) {
          throw new Error(isZh ? '当前镜像不可用，请选择可用镜像。' : 'Selected image is unavailable.');
        }
        if (selectedImageOption?.needsRegister) {
          await axios.post(`${APP_ENDPOINTS.apiBase}/images/register`, {
            tag: editForm.image_tag,
            source_dir: 'manual',
          });
        }
        const normalizedCpuCores = clampCpuCores(Number(paramDraft.cpu_cores));
        const normalizedMemoryMb = clampMemoryMb(Number(paramDraft.memory_mb));
        const normalizedStorageGb = clampStorageGb(Number(paramDraft.storage_gb));
        payload.cpu_cores = normalizedCpuCores;
        payload.memory_mb = normalizedMemoryMb;
        payload.storage_gb = normalizedStorageGb;
        setEditForm((p) => ({
          ...p,
          cpu_cores: normalizedCpuCores,
          memory_mb: normalizedMemoryMb,
          storage_gb: normalizedStorageGb,
        }));
        setParamDraft((p) => ({
          ...p,
          cpu_cores: String(normalizedCpuCores),
          memory_mb: String(normalizedMemoryMb),
          storage_gb: String(normalizedStorageGb),
        }));
      }
      if (mode === 'params') {
        payload.llm_provider = editForm.llm_provider;
        payload.llm_model = editForm.llm_model;
        payload.api_base = editForm.api_base;
        if (editForm.api_key.trim()) payload.api_key = editForm.api_key.trim();
        payload.temperature = clampTemperature(Number(editForm.temperature));
        payload.top_p = Number(editForm.top_p);
        const normalizedMaxTokens = clampMaxTokens(Number(paramDraft.max_tokens));
        payload.max_tokens = normalizedMaxTokens;
        setEditForm((p) => ({
          ...p,
          max_tokens: normalizedMaxTokens,
        }));
        setParamDraft((p) => ({ ...p, max_tokens: String(normalizedMaxTokens) }));
      }
      if (mode === 'agent') {
        payload.agents_md = editForm.agents_md;
        payload.soul_md = editForm.soul_md;
        payload.user_md = editForm.user_md;
        payload.tools_md = editForm.tools_md;
        payload.identity_md = editForm.identity_md;
      }

      await axios.put(`${APP_ENDPOINTS.apiBase}/bots/${targetBotId}`, payload);
      await refresh();
      setShowBaseModal(false);
      setShowParamModal(false);
      setShowAgentModal(false);
      notify(t.configUpdated, { tone: 'success' });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.saveFail;
      notify(msg, { tone: 'error' });
    } finally {
      setIsSaving(false);
    }
  };

  const removeBot = async (botId?: string) => {
    const targetId = botId || selectedBot?.id;
    if (!targetId) return;
    const ok = await confirm({
      title: t.delete,
      message: t.deleteBotConfirm(targetId),
      tone: 'warning',
    });
    if (!ok) return;
    try {
      await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${targetId}`, { params: { delete_workspace: true } });
      await refresh();
      if (selectedBotId === targetId) setSelectedBotId('');
      notify(t.deleteBotDone, { tone: 'success' });
    } catch {
      notify(t.deleteFail, { tone: 'error' });
    }
  };

  const clearConversationHistory = async () => {
    if (!selectedBot) return;
    const target = selectedBot.name || selectedBot.id;
    const ok = await confirm({
      title: t.clearHistory,
      message: t.clearHistoryConfirm(target),
      tone: 'warning',
    });
    if (!ok) return;
    try {
      await axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/messages`);
      setBotMessages(selectedBot.id, []);
      notify(t.clearHistoryDone, { tone: 'success' });
    } catch (error: any) {
      const msg = error?.response?.data?.detail || t.clearHistoryFail;
      notify(msg, { tone: 'error' });
    }
  };

  const exportConversationJson = () => {
    if (!selectedBot) return;
    try {
      const payload = {
        bot_id: selectedBot.id,
        bot_name: selectedBot.name || selectedBot.id,
        exported_at: new Date().toISOString(),
        message_count: conversation.length,
        messages: conversation.map((m) => ({
          id: m.id || null,
          role: m.role,
          text: m.text,
          attachments: m.attachments || [],
          kind: m.kind || 'final',
          feedback: m.feedback || null,
          ts: m.ts,
          datetime: new Date(m.ts).toISOString(),
        })),
      };
      const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
      const url = URL.createObjectURL(blob);
      const stamp = new Date().toISOString().replace(/[:.]/g, '-');
      const filename = `${selectedBot.id}-conversation-${stamp}.json`;
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    } catch {
      notify(t.exportHistoryFail, { tone: 'error' });
    }
  };

  const tabMap: Record<AgentTab, keyof typeof editForm> = {
    AGENTS: 'agents_md',
    SOUL: 'soul_md',
    USER: 'user_md',
    TOOLS: 'tools_md',
    IDENTITY: 'identity_md',
  };

  const renderWorkspaceNodes = (nodes: WorkspaceNode[]): ReactNode[] => {
    const rendered: ReactNode[] = [];
    if (workspaceParentPath !== null) {
      rendered.push(
        <button
          key="dir:.."
          className="workspace-entry dir nav-up"
          onClick={() => void loadWorkspaceTree(selectedBotId, workspaceParentPath || '')}
          title={t.goUpTitle}
        >
          <FolderOpen size={14} />
          <span className="workspace-entry-name">..</span>
          <span className="workspace-entry-meta">{t.goUp}</span>
        </button>,
      );
    }

    nodes.forEach((node) => {
      const key = `${node.type}:${node.path}`;
      if (node.type === 'dir') {
        rendered.push(
          <button
            key={key}
            className="workspace-entry dir"
            onClick={() => void loadWorkspaceTree(selectedBotId, node.path)}
            title={t.openFolderTitle}
          >
            <FolderOpen size={14} />
            <span className="workspace-entry-name" title={node.name}>{node.name}</span>
            <span className="workspace-entry-meta">{t.folder}</span>
          </button>,
        );
        return;
      }

      const previewable = isPreviewableWorkspaceFile(node);
      const downloadOnlyFile = isPdfPath(node.path) || isOfficePath(node.path);
      rendered.push(
        <button
          key={key}
          className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
          disabled={workspaceFileLoading}
          aria-disabled={!previewable || workspaceFileLoading}
          onClick={() => {
            if (workspaceFileLoading) return;
            if (!previewable) return;
            void openWorkspaceFilePreview(node.path);
          }}
          onMouseEnter={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
          onMouseLeave={hideWorkspaceHoverCard}
          onFocus={(event) => showWorkspaceHoverCard(node, event.currentTarget)}
          onBlur={hideWorkspaceHoverCard}
          title={previewable ? (downloadOnlyFile ? t.download : t.previewTitle) : t.fileNotPreviewable}
        >
          <FileText size={14} />
          <span className="workspace-entry-name" title={node.name}>{node.name}</span>
          <span className="workspace-entry-meta mono">{node.ext || '-'}</span>
        </button>,
      );
    });
    return rendered;
  };

  return (
    <>
      <div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''}`}>
        {!compactMode ? (
        <section className="panel stack ops-bot-list">
          <div className="row-between">
            <h2 style={{ fontSize: 18 }}>
              {normalizedBotListQuery
                ? `${t.titleBots} (${filteredBots.length}/${bots.length})`
                : `${t.titleBots} (${bots.length})`}
            </h2>
            <div className="ops-list-actions">
              <LucentIconButton
                className="btn btn-secondary btn-sm icon-btn"
                onClick={onOpenImageFactory}
                tooltip={t.manageImages}
                aria-label={t.manageImages}
              >
                <Boxes size={14} />
              </LucentIconButton>
              <LucentIconButton
                className="btn btn-primary btn-sm icon-btn"
                onClick={onOpenCreateWizard}
                tooltip={t.newBot}
                aria-label={t.newBot}
              >
                <Plus size={14} />
              </LucentIconButton>
            </div>
          </div>

          <div className="ops-bot-list-toolbar">
            <div className="ops-searchbar">
              <input
                className="input ops-search-input ops-search-input-with-icon"
                value={botListQuery}
                onChange={(e) => setBotListQuery(e.target.value)}
                placeholder={t.botSearchPlaceholder}
                aria-label={t.botSearchPlaceholder}
                autoComplete="off"
                autoCorrect="off"
                autoCapitalize="none"
                spellCheck={false}
                name="bot-search"
              />
              <button
                type="button"
                className="ops-search-inline-btn"
                onClick={() => {
                  if (botListQuery.trim()) {
                    setBotListQuery('');
                    setBotListPage(1);
                    return;
                  }
                  setBotListPage(1);
                }}
                title={botListQuery.trim() ? t.clearSearch : t.searchAction}
                aria-label={botListQuery.trim() ? t.clearSearch : t.searchAction}
              >
                {botListQuery.trim() ? <X size={14} /> : <Search size={14} />}
              </button>
            </div>
          </div>

          <div className="list-scroll">
            {pagedBots.map((bot) => {
              const selected = selectedBotId === bot.id;
              const controlState = controlStateByBot[bot.id];
              const isOperating = operatingBotId === bot.id;
              const isStarting = controlState === 'starting';
              const isStopping = controlState === 'stopping';
              return (
                <div key={bot.id} className={`ops-bot-card ${selected ? 'is-active' : ''}`} onClick={() => setSelectedBotId(bot.id)}>
                  <span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
                  <div className="row-between ops-bot-top">
                      <div className="ops-bot-name-wrap">
                        <div className="ops-bot-name-row">
                          {bot.has_access_password ? (
                            <span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
                              <Lock size={12} />
                            </span>
                          ) : null}
                          <div className="ops-bot-name">{bot.name}</div>
                          <LucentIconButton
                            className="ops-bot-open-inline"
                          onClick={(e) => {
                            e.stopPropagation();
                            const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
                            window.open(target, '_blank', 'noopener,noreferrer');
                          }}
                          tooltip={isZh ? '新页面打开' : 'Open in new page'}
                          aria-label={isZh ? '新页面打开' : 'Open in new page'}
                        >
                          <ExternalLink size={11} />
                        </LucentIconButton>
                      </div>
                      <div className="mono ops-bot-id">{bot.id}</div>
                    </div>
                    <div className="ops-bot-top-actions">
                      <span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
                    </div>
                  </div>
                  <div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
                  <div className="ops-bot-actions">
                    <LucentIconButton
                      className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
                      onClick={(e) => {
                        e.stopPropagation();
                        openResourceMonitor(bot.id);
                      }}
                      tooltip={isZh ? '资源监测' : 'Resource Monitor'}
                      aria-label={isZh ? '资源监测' : 'Resource Monitor'}
                    >
                      <Gauge size={14} />
                    </LucentIconButton>
                    {bot.docker_status === 'RUNNING' ? (
                      <LucentIconButton
                        className="btn btn-sm ops-bot-icon-btn ops-bot-action-stop"
                        disabled={isOperating}
                        onClick={(e) => {
                          e.stopPropagation();
                          void stopBot(bot.id, bot.docker_status);
                        }}
                        tooltip={t.stop}
                        aria-label={t.stop}
                      >
                        {isStopping ? (
                          <span className="ops-control-pending">
                            <span className="ops-control-dots" aria-hidden="true">
                              <i />
                              <i />
                              <i />
                            </span>
                          </span>
                        ) : <Square size={14} />}
                      </LucentIconButton>
                    ) : (
                      <LucentIconButton
                        className="btn btn-sm ops-bot-icon-btn ops-bot-action-start"
                        disabled={isOperating}
                        onClick={(e) => {
                          e.stopPropagation();
                          void startBot(bot.id, bot.docker_status);
                        }}
                        tooltip={t.start}
                        aria-label={t.start}
                      >
                        {isStarting ? (
                          <span className="ops-control-pending">
                            <span className="ops-control-dots" aria-hidden="true">
                              <i />
                              <i />
                              <i />
                            </span>
                          </span>
                        ) : <Power size={14} />}
                      </LucentIconButton>
                    )}
                    <LucentIconButton
                      className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
                      onClick={(e) => {
                        e.stopPropagation();
                        void removeBot(bot.id);
                      }}
                      tooltip={t.delete}
                      aria-label={t.delete}
                    >
                      <Trash2 size={14} />
                    </LucentIconButton>
                  </div>
                </div>
              );
            })}
            {filteredBots.length === 0 ? (
              <div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
            ) : null}
          </div>
          <div className="ops-bot-list-pagination">
            <LucentIconButton
              className="btn btn-secondary btn-sm icon-btn"
              onClick={() => setBotListPage((p) => Math.max(1, p - 1))}
              disabled={botListPage <= 1}
              tooltip={t.paginationPrev}
              aria-label={t.paginationPrev}
            >
              <ChevronLeft size={14} />
            </LucentIconButton>
            <div className="ops-bot-list-page-indicator">{t.paginationPage(botListPage, botListTotalPages)}</div>
            <LucentIconButton
              className="btn btn-secondary btn-sm icon-btn"
              onClick={() => setBotListPage((p) => Math.min(botListTotalPages, p + 1))}
              disabled={botListPage >= botListTotalPages}
              tooltip={t.paginationNext}
              aria-label={t.paginationNext}
            >
              <ChevronRight size={14} />
            </LucentIconButton>
          </div>
        </section>
        ) : null}

        <section className={`panel ops-chat-panel ${compactMode && isCompactMobile && compactPanelTab !== 'chat' ? 'ops-compact-hidden' : ''}`}>
          {selectedBot ? (
            <div className="ops-chat-shell">
                <div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
                <div className="ops-chat-scroll">
                  {conversation.length === 0 ? (
                    <div className="ops-chat-empty">
                      {t.noConversation}
                    </div>
                  ) : (
                    conversationNodes
                  )}

                  {isThinking ? (
                    <div className="ops-chat-row is-assistant">
                      <div className="ops-chat-item is-assistant">
                        <div className="ops-avatar bot" title="Nanobot">
                          <img src={nanobotLogo} alt="Nanobot" />
                        </div>
                        <div className="ops-thinking-bubble">
                          <div className="ops-thinking-cloud">
                            <span className="dot" />
                            <span className="dot" />
                            <span className="dot" />
                          </div>
                          <div className="ops-thinking-text">{t.thinking}</div>
                        </div>
                      </div>
                    </div>
                  ) : null}

                  <div ref={chatBottomRef} />
                </div>

                <div className="ops-chat-dock">
                  {(quotedReply || pendingAttachments.length > 0) ? (
                    <div className="ops-chat-top-context">
                      {quotedReply ? (
                        <div className="ops-composer-quote" aria-live="polite">
                          <div className="ops-composer-quote-head">
                            <span>{t.quotedReplyLabel}</span>
                            <button
                              type="button"
                              className="ops-chat-inline-action ops-no-tip-icon-btn"
                              onClick={() => setQuotedReply(null)}
                            >
                              <X size={12} />
                            </button>
                          </div>
                          <div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
                        </div>
                      ) : null}
                      {pendingAttachments.length > 0 ? (
                        <div className="ops-pending-files">
                          {pendingAttachments.map((p) => (
                            <span key={p} className="ops-pending-chip mono">
                              {(() => {
                                const filePath = normalizeDashboardAttachmentPath(p);
                                const fileAction = workspaceFileAction(filePath);
                                const filename = filePath.split('/').pop() || filePath;
                                return (
                                  <a
                                    className="ops-attach-link mono ops-pending-open"
                                    href="#"
                                    onClick={(event) => {
                                      event.preventDefault();
                                      event.stopPropagation();
                                      void openWorkspacePathFromChat(filePath);
                                    }}
                                  >
                                    {fileAction === 'download' ? (
                                      <Download size={12} className="ops-attach-link-icon" />
                                    ) : fileAction === 'preview' ? (
                                      <Eye size={12} className="ops-attach-link-icon" />
                                    ) : (
                                      <FileText size={12} className="ops-attach-link-icon" />
                                    )}
                                    <span className="ops-attach-link-name">{filename}</span>
                                  </a>
                                );
                              })()}
                              <button
                                type="button"
                                className="ops-chat-inline-action ops-no-tip-icon-btn"
                                onClick={(event) => {
                                  event.preventDefault();
                                  event.stopPropagation();
                                  setPendingAttachments((prev) => prev.filter((v) => v !== p));
                                }}
                              >
                                <X size={12} />
                              </button>
                            </span>
                          ))}
                        </div>
                      ) : null}
                    </div>
                  ) : null}
                <div className="ops-composer">
                  <input
                    ref={filePickerRef}
                    type="file"
                    multiple
                    onChange={onPickAttachments}
                    style={{ display: 'none' }}
                  />
                  <div className="ops-composer-shell">
                    <textarea
                      ref={composerTextareaRef}
                      className="input ops-composer-input"
                      value={command}
                      onChange={(e) => setCommand(e.target.value)}
                      onKeyDown={onComposerKeyDown}
                      disabled={!canChat}
                      placeholder={
                        canChat
                          ? t.inputPlaceholder
                          : t.disabledPlaceholder
                      }
                    />
                    <div className="ops-composer-tools-right">
                      <LucentIconButton
                        className="ops-composer-inline-btn"
                        disabled={!canChat}
                        onClick={onVoiceInput}
                        tooltip={t.voiceInput}
                        aria-label={t.voiceInput}
                      >
                        <Mic size={16} />
                      </LucentIconButton>
                      <LucentIconButton
                        className="ops-composer-inline-btn"
                        disabled={!canChat || isUploadingAttachments}
                        onClick={triggerPickAttachments}
                        tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
                        aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
                      >
                        <Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
                      </LucentIconButton>
                      <button
                        className={`ops-composer-submit-btn ${isChatEnabled && (isThinking || isSending) ? 'is-interrupt' : ''}`}
                        disabled={
                          isChatEnabled && (isThinking || isSending)
                            ? Boolean(interruptingByBot[selectedBot.id])
                            : (!isChatEnabled || (!command.trim() && pendingAttachments.length === 0 && !quotedReply))
                        }
                        onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
                        aria-label={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
                        title={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
                      >
                        {isChatEnabled && (isThinking || isSending) ? (
                          <Square size={15} />
                        ) : (
                          <ArrowUp size={18} />
                        )}
                      </button>
                    </div>
                  </div>
                </div>
                </div>
                {isUploadingAttachments ? (
                  <div className="ops-upload-progress" aria-live="polite">
                    <div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
                      <div
                        className="ops-upload-progress-fill"
                        style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
                      />
                    </div>
                    <span className="ops-upload-progress-text mono">
                      {attachmentUploadPercent === null
                        ? t.uploadingFile
                        : `${t.uploadingFile} ${attachmentUploadPercent}%`}
                    </span>
                  </div>
                ) : null}
                {!canChat ? (
                  <div className="ops-chat-disabled-mask">
                    <div className="ops-chat-disabled-card">
                      {selectedBotControlState === 'starting'
                        ? t.botStarting
                        : selectedBotControlState === 'stopping'
                          ? t.botStopping
                          : t.chatDisabled}
                    </div>
                  </div>
                ) : null}
              </div>
            </div>
          ) : (
            <div style={{ color: 'var(--muted)' }}>
              {forcedBotMissing
                ? `${t.selectBot}: ${String(forcedBotId).trim()}`
                : t.selectBot}
            </div>
          )}
        </section>

        <section className={`panel stack ops-runtime-panel ${compactMode && isCompactMobile && compactPanelTab !== 'runtime' ? 'ops-compact-hidden' : ''}`}>
          {selectedBot ? (
            <div className="ops-runtime-shell">
              <div className="row-between ops-runtime-head">
                <h2 style={{ fontSize: 18 }}>{t.runtime}</h2>
                <div className="ops-panel-tools" ref={runtimeMenuRef}>
                  <LucentIconButton
                    className="btn btn-secondary btn-sm icon-btn"
                    onClick={() => setRuntimeViewMode((m) => (m === 'visual' ? 'text' : 'visual'))}
                    tooltip={runtimeViewMode === 'visual' ? (isZh ? '切换为文字面板' : 'Switch to text panel') : (isZh ? '切换为机器人面板' : 'Switch to bot panel')}
                    aria-label={runtimeViewMode === 'visual' ? (isZh ? '切换为文字面板' : 'Switch to text panel') : (isZh ? '切换为机器人面板' : 'Switch to bot panel')}
                  >
                    <Repeat2 size={14} />
                  </LucentIconButton>
                  <LucentIconButton
                    className="btn btn-secondary btn-sm icon-btn"
                    onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
                    disabled={operatingBotId === selectedBot.id}
                    tooltip={t.restart}
                    aria-label={t.restart}
                  >
                    <RefreshCw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
                  </LucentIconButton>
                  <LucentIconButton
                    className="btn btn-secondary btn-sm icon-btn"
                    onClick={() => setRuntimeMenuOpen((v) => !v)}
                    tooltip={runtimeMoreLabel}
                    aria-label={runtimeMoreLabel}
                    aria-haspopup="menu"
                    aria-expanded={runtimeMenuOpen}
                  >
                    <EllipsisVertical size={14} />
                  </LucentIconButton>
                  {runtimeMenuOpen ? (
                    <div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          void (async () => {
                            const detail = await ensureSelectedBotDetail();
                            applyEditFormFromBot(detail);
                            setProviderTestResult('');
                            setShowBaseModal(true);
                          })();
                        }}
                      >
                        <Settings2 size={14} />
                        <span>{t.base}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          void (async () => {
                            const detail = await ensureSelectedBotDetail();
                            applyEditFormFromBot(detail);
                            setShowParamModal(true);
                          })();
                        }}
                      >
                        <SlidersHorizontal size={14} />
                        <span>{t.params}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          if (selectedBot) void loadChannels(selectedBot.id);
                          setShowChannelModal(true);
                        }}
                      >
                        <Waypoints size={14} />
                        <span>{t.channels}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          if (!selectedBot) return;
                          void loadBotEnvParams(selectedBot.id);
                          setShowEnvParamsModal(true);
                        }}
                      >
                        <Settings2 size={14} />
                        <span>{t.envParams}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          if (!selectedBot) return;
                          void loadBotSkills(selectedBot.id);
                          setShowSkillsModal(true);
                        }}
                      >
                        <Hammer size={14} />
                        <span>{t.skills}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          if (selectedBot) void loadCronJobs(selectedBot.id);
                          setShowCronModal(true);
                        }}
                      >
                        <Clock3 size={14} />
                        <span>{t.cronViewer}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          void (async () => {
                            const detail = await ensureSelectedBotDetail();
                            applyEditFormFromBot(detail);
                            setShowAgentModal(true);
                          })();
                        }}
                      >
                        <FileText size={14} />
                        <span>{t.agent}</span>
                      </button>
                      <button
                        className="ops-more-item"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          exportConversationJson();
                        }}
                      >
                        <Save size={14} />
                        <span>{t.exportHistory}</span>
                      </button>
                      <button
                        className="ops-more-item danger"
                        role="menuitem"
                        onClick={() => {
                          setRuntimeMenuOpen(false);
                          void clearConversationHistory();
                        }}
                      >
                        <Trash2 size={14} />
                        <span>{t.clearHistory}</span>
                      </button>
                    </div>
                  ) : null}
                </div>
              </div>

              <div className="ops-runtime-scroll">
              <div
                className={`card ops-runtime-card ops-runtime-state-card ${runtimeViewMode === 'visual' ? 'is-visual' : ''}`}
                onDoubleClick={() => setRuntimeViewMode((m) => (m === 'visual' ? 'text' : 'visual'))}
                title={isZh ? '双击切换动画/文字状态视图' : 'Double click to toggle visual/text state view'}
              >
                {runtimeViewMode === 'visual' ? (
                  <div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
                    <div className="ops-state-model mono">{selectedBot.llm_model || '-'}</div>
                    <div className="ops-state-face" aria-hidden="true">
                      <span className="ops-state-eye left" />
                      <span className="ops-state-eye right" />
                    </div>
                    <div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
                    {displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
                    {displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
                    {displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
                  </div>
                ) : (
                  <>
                    <div className="ops-runtime-row"><span>{t.container}</span><strong className="mono">{selectedBot.docker_status}</strong></div>
                    <div className="ops-runtime-row"><span>{t.current}</span><strong className="mono">{displayState}</strong></div>
                    <div className="ops-runtime-row">
                      <span>{t.lastAction}</span>
                      <div className="ops-runtime-action-inline">
                        <strong className="ops-runtime-action-text">{runtimeActionDisplay}</strong>
                        {runtimeActionHasMore ? (
                          <LucentIconButton
                            className="ops-runtime-expand-btn"
                            onClick={() => setShowRuntimeActionModal(true)}
                            tooltip={isZh ? '查看完整内容' : 'Show full content'}
                            aria-label={isZh ? '查看完整内容' : 'Show full content'}
                          >
                            …
                          </LucentIconButton>
                        ) : null}
                      </div>
                    </div>
                    <div className="ops-runtime-row"><span>Provider</span><strong className="mono">{selectedBot.llm_provider || '-'}</strong></div>
                    <div className="ops-runtime-row"><span>Model</span><strong className="mono">{selectedBot.llm_model || '-'}</strong></div>
                  </>
                )}
              </div>

              <div className="card ops-runtime-card">
                <div className="section-mini-title">{t.workspaceOutputs}</div>
                {workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
                <div className="workspace-toolbar">
                  <div className="workspace-path-wrap">
                    <div className="workspace-path mono" title={workspacePathDisplay}>
                      {workspacePathDisplay}
                    </div>
                  </div>
                  <div className="workspace-toolbar-actions">
                    <LucentIconButton
                      className="workspace-refresh-icon-btn"
                      disabled={workspaceLoading || !selectedBotId}
                      onClick={() => void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath)}
                      tooltip={lc.refreshHint}
                      aria-label={lc.refreshHint}
                    >
                      <RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
                    </LucentIconButton>
                    <label className="workspace-auto-switch" title={lc.autoRefresh}>
                      <span className="workspace-auto-switch-label">{lc.autoRefresh}</span>
                      <input
                        type="checkbox"
                        checked={workspaceAutoRefresh}
                        onChange={() => setWorkspaceAutoRefresh((v) => !v)}
                        aria-label={t.autoRefresh}
                      />
                      <span className="workspace-auto-switch-track" />
                    </label>
                  </div>
                </div>
                <div className="workspace-search-toolbar">
                  <div className="ops-searchbar">
                    <input
                      className="input ops-search-input ops-search-input-with-icon"
                      value={workspaceQuery}
                      onChange={(e) => setWorkspaceQuery(e.target.value)}
                      placeholder={t.workspaceSearchPlaceholder}
                      aria-label={t.workspaceSearchPlaceholder}
                    />
                    <button
                      type="button"
                      className="ops-search-inline-btn"
                      onClick={() => {
                        if (workspaceQuery.trim()) {
                          setWorkspaceQuery('');
                          return;
                        }
                        setWorkspaceQuery((v) => v.trim());
                      }}
                      title={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
                      aria-label={workspaceQuery.trim() ? t.clearSearch : t.searchAction}
                    >
                      {workspaceQuery.trim() ? <X size={14} /> : <Search size={14} />}
                    </button>
                  </div>
                </div>
                <div className="workspace-panel">
                  <div className="workspace-list">
                    {workspaceLoading || workspaceSearchLoading ? (
                      <div className="ops-empty-inline">{t.loadingDir}</div>
                    ) : renderWorkspaceNodes(filteredWorkspaceEntries).length === 0 ? (
                      <div className="ops-empty-inline">{normalizedWorkspaceQuery ? t.workspaceSearchNoResult : t.emptyDir}</div>
                    ) : (
                      renderWorkspaceNodes(filteredWorkspaceEntries)
                    )}
                  </div>
                  <div className="workspace-hint">
                    {workspaceFileLoading
                      ? t.openingPreview
                      : t.workspaceHint}
                  </div>
                </div>
                {workspaceFiles.length === 0 ? (
                  <div className="ops-empty-inline">{t.noPreviewFile}</div>
                ) : null}
              </div>

              </div>
            </div>
          ) : (
            <div style={{ color: 'var(--muted)' }}>
              {forcedBotMissing
                ? `${t.noTelemetry}: ${String(forcedBotId).trim()}`
                : t.noTelemetry}
            </div>
          )}
        </section>
      </div>
      {compactMode && isCompactMobile ? (
        <LucentIconButton
          className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
          onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))}
          tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}
          aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}
        >
          {compactPanelTab === 'chat' ? <Activity size={18} /> : <MessageSquareText size={18} />}
        </LucentIconButton>
      ) : null}

      {showResourceModal && (
        <div className="modal-mask" onClick={() => setShowResourceModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{isZh ? '资源监测' : 'Resource Monitor'}</h3>
                <span className="modal-sub mono">{resourceBot?.name || resourceBotId}</span>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton
                  className="btn btn-secondary btn-sm icon-btn"
                  onClick={() => void loadResourceSnapshot(resourceBotId)}
                  tooltip={isZh ? '立即刷新' : 'Refresh now'}
                  aria-label={isZh ? '立即刷新' : 'Refresh now'}
                >
                  <RefreshCw size={14} className={resourceLoading ? 'animate-spin' : ''} />
                </LucentIconButton>
                <LucentIconButton
                  className="btn btn-secondary btn-sm icon-btn"
                  onClick={() => setShowResourceModal(false)}
                  tooltip={t.close}
                  aria-label={t.close}
                >
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>

            {resourceError ? <div className="card">{resourceError}</div> : null}
            {resourceSnapshot ? (
              <div className="stack">
                <div className="card summary-grid">
                  <div>{isZh ? '容器状态' : 'Container'}: <strong className="mono">{resourceSnapshot.docker_status}</strong></div>
                  <div>{isZh ? '容器名' : 'Container Name'}: <span className="mono">{resourceSnapshot.bot_id ? `worker_${resourceSnapshot.bot_id}` : '-'}</span></div>
                  <div>{isZh ? '基础镜像' : 'Base Image'}: <span className="mono">{resourceBot?.image_tag || '-'}</span></div>
                  <div>Provider/Model: <span className="mono">{resourceBot?.llm_provider || '-'} / {resourceBot?.llm_model || '-'}</span></div>
                  <div>{isZh ? '采样时间' : 'Collected'}: <span className="mono">{resourceSnapshot.collected_at}</span></div>
                  <div>{isZh ? '策略说明' : 'Policy'}: <strong>{isZh ? '资源值 0 = 不限制' : 'Value 0 = Unlimited'}</strong></div>
                </div>

                <div className="grid-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
                  <div className="card stack">
                    <div className="section-mini-title">{isZh ? '配置配额' : 'Configured Limits'}</div>
                    <div className="ops-runtime-row"><span>CPU</span><strong>{Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : resourceSnapshot.configured.cpu_cores}</strong></div>
                    <div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.memory_mb} MB`}</strong></div>
                    <div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${resourceSnapshot.configured.storage_gb} GB`}</strong></div>
                  </div>

                  <div className="card stack">
                    <div className="section-mini-title">{isZh ? 'Docker 实际限制' : 'Docker Runtime Limits'}</div>
                    <div className="ops-runtime-row"><span>CPU</span><strong>{resourceSnapshot.runtime.limits.cpu_cores ? resourceSnapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(resourceSnapshot.configured.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
                    <div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{resourceSnapshot.runtime.limits.memory_bytes ? formatBytes(resourceSnapshot.runtime.limits.memory_bytes) : (Number(resourceSnapshot.configured.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-')}</strong></div>
                    <div className="ops-runtime-row"><span>{isZh ? '存储' : 'Storage'}</span><strong>{resourceSnapshot.runtime.limits.storage_bytes ? formatBytes(resourceSnapshot.runtime.limits.storage_bytes) : (resourceSnapshot.runtime.limits.storage_opt_raw || (Number(resourceSnapshot.configured.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : '-'))}</strong></div>
                  </div>
                </div>

                <div className="card stack">
                  <div className="section-mini-title">{isZh ? '实时使用' : 'Live Usage'}</div>
                  <div className="ops-runtime-row"><span>CPU</span><strong>{formatPercent(resourceSnapshot.runtime.usage.cpu_percent)}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '内存' : 'Memory'}</span><strong>{formatBytes(resourceSnapshot.runtime.usage.memory_bytes)} / {resourceSnapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(resourceSnapshot.runtime.usage.memory_limit_bytes) : '-'}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '内存占比' : 'Memory %'}</span><strong>{formatPercent(resourceSnapshot.runtime.usage.memory_percent)}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '工作区占用' : 'Workspace Usage'}</span><strong>{formatBytes(resourceSnapshot.workspace.usage_bytes)} / {resourceSnapshot.workspace.configured_limit_bytes ? formatBytes(resourceSnapshot.workspace.configured_limit_bytes) : '-'}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '工作区占比' : 'Workspace %'}</span><strong>{formatPercent(resourceSnapshot.workspace.usage_percent)}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '网络 I/O' : 'Network I/O'}</span><strong>RX {formatBytes(resourceSnapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(resourceSnapshot.runtime.usage.network_tx_bytes)}</strong></div>
                  <div className="ops-runtime-row"><span>{isZh ? '磁盘 I/O' : 'Block I/O'}</span><strong>R {formatBytes(resourceSnapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(resourceSnapshot.runtime.usage.blk_write_bytes)}</strong></div>
                  <div className="ops-runtime-row"><span>PIDs</span><strong>{resourceSnapshot.runtime.usage.pids || 0}</strong></div>
                </div>

                <div className="field-label">
                  {resourceSnapshot.note}
                  {isZh ? '（界面规则：资源配置填写 0 表示不限制）' : ' (UI rule: value 0 means unlimited)'}
                </div>
              </div>
            ) : (
              <div className="ops-empty-inline">{resourceLoading ? (isZh ? '读取中...' : 'Loading...') : (isZh ? '暂无监控数据' : 'No metrics')}</div>
            )}
          </div>
        </div>
      )}

      {showBaseModal && (
        <div className="modal-mask" onClick={() => setShowBaseModal(false)}>
          <div className="modal-card" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.baseConfig}</h3>
                <span className="modal-sub">{t.baseConfigSub}</span>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowBaseModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>

            <label className="field-label">{t.botIdReadonly}</label>
            <input className="input" value={selectedBot?.id || ''} disabled />

            <label className="field-label">{t.botName}</label>
            <input className="input" value={editForm.name} onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))} placeholder={t.botNamePlaceholder} />

            <label className="field-label">{t.accessPassword}</label>
            <input
              className="input"
              type="password"
              value={editForm.access_password}
              onChange={(e) => setEditForm((p) => ({ ...p, access_password: e.target.value }))}
              placeholder={t.accessPasswordPlaceholder}
            />

            <label className="field-label">{t.baseImageReadonly}</label>
            <select
              className="select"
              value={editForm.image_tag}
              onChange={(e) => setEditForm((p) => ({ ...p, image_tag: e.target.value }))}
            >
              {baseImageOptions.map((img) => (
                <option key={img.tag} value={img.tag} disabled={img.disabled}>
                  {img.label}
                </option>
              ))}
            </select>
            {baseImageOptions.find((opt) => opt.tag === editForm.image_tag)?.needsRegister ? (
              <div className="field-label" style={{ color: 'var(--warning)' }}>
                {isZh ? '该镜像尚未登记，保存时会自动加入镜像注册表。' : 'This image is not registered yet. It will be auto-registered on save.'}
              </div>
            ) : null}

            <label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
            <input
              className="input"
              type="number"
              min="0"
              max="16"
              step="0.1"
              value={paramDraft.cpu_cores}
              onChange={(e) => setParamDraft((p) => ({ ...p, cpu_cores: e.target.value }))}
            />
            <label className="field-label">{isZh ? '内存 (MB)' : 'Memory (MB)'}</label>
            <input
              className="input"
              type="number"
              min="0"
              max="65536"
              step="128"
              value={paramDraft.memory_mb}
              onChange={(e) => setParamDraft((p) => ({ ...p, memory_mb: e.target.value }))}
            />
            <label className="field-label">{isZh ? '存储 (GB)' : 'Storage (GB)'}</label>
            <input
              className="input"
              type="number"
              min="0"
              max="1024"
              step="1"
              value={paramDraft.storage_gb}
              onChange={(e) => setParamDraft((p) => ({ ...p, storage_gb: e.target.value }))}
            />
            <div className="field-label">{isZh ? '提示：填写 0 表示不限制（保存后需手动重启 Bot 生效）。' : 'Tip: value 0 means unlimited (takes effect after manual bot restart).'}</div>
            <div className="row-between">
              <button className="btn btn-secondary" onClick={() => setShowBaseModal(false)}>{t.cancel}</button>
              <button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('base')}>{t.save}</button>
            </div>
          </div>
        </div>
      )}

      {showParamModal && (
        <div className="modal-mask" onClick={() => setShowParamModal(false)}>
          <div className="modal-card" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.modelParams}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowParamModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <label className="field-label">Provider</label>
            <select className="select" value={editForm.llm_provider} onChange={(e) => onBaseProviderChange(e.target.value)}>
              <option value="openrouter">openrouter</option>
              <option value="dashscope">dashscope (aliyun qwen)</option>
              <option value="openai">openai</option>
              <option value="deepseek">deepseek</option>
              <option value="kimi">kimi (moonshot)</option>
              <option value="minimax">minimax</option>
            </select>

            <label className="field-label">{t.modelName}</label>
            <input className="input" value={editForm.llm_model} onChange={(e) => setEditForm((p) => ({ ...p, llm_model: e.target.value }))} placeholder={t.modelNamePlaceholder} />

            <label className="field-label">{t.newApiKey}</label>
            <input className="input" type="password" value={editForm.api_key} onChange={(e) => setEditForm((p) => ({ ...p, api_key: e.target.value }))} placeholder={t.newApiKeyPlaceholder} />

            <label className="field-label">API Base</label>
            <input className="input" value={editForm.api_base} onChange={(e) => setEditForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="API Base URL" />

            <div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
              {providerPresets[editForm.llm_provider]?.note[noteLocale]}
            </div>
            <button className="btn btn-secondary" onClick={() => void testProviderConnection()} disabled={isTestingProvider}>
              {isTestingProvider ? t.testing : t.testModelConnection}
            </button>
            {providerTestResult && <div className="card">{providerTestResult}</div>}

            <div className="slider-row">
              <label className="field-label">Temperature: {Number(editForm.temperature).toFixed(2)}</label>
              <input type="range" min="0" max="1" step="0.01" value={editForm.temperature} onChange={(e) => setEditForm((p) => ({ ...p, temperature: clampTemperature(Number(e.target.value)) }))} />
            </div>
            <div className="slider-row">
              <label className="field-label">Top P: {Number(editForm.top_p).toFixed(2)}</label>
              <input type="range" min="0" max="1" step="0.01" value={editForm.top_p} onChange={(e) => setEditForm((p) => ({ ...p, top_p: Number(e.target.value) }))} />
            </div>
            <label className="field-label">Max Tokens</label>
            <input
              className="input"
              type="number"
              step="1"
              min="256"
              max="32768"
              value={paramDraft.max_tokens}
              onChange={(e) => setParamDraft((p) => ({ ...p, max_tokens: e.target.value }))}
            />
            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
              {[4096, 8192, 16384, 32768].map((value) => (
                <button
                  key={value}
                  className="btn btn-secondary btn-sm"
                  type="button"
                  onClick={() => setParamDraft((p) => ({ ...p, max_tokens: String(value) }))}
                >
                  {value}
                </button>
              ))}
            </div>
            <div className="row-between">
              <button className="btn btn-secondary" onClick={() => setShowParamModal(false)}>{t.cancel}</button>
              <button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('params')}>{t.saveParams}</button>
            </div>
          </div>
        </div>
      )}

      {showChannelModal && (
        <div className="modal-mask" onClick={() => setShowChannelModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{lc.wizardSectionTitle}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowChannelModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
              {lc.wizardSectionDesc}
            </div>
            <div className="card">
              <div className="section-mini-title">{lc.globalDeliveryTitle}</div>
              <div className="field-label">{lc.globalDeliveryDesc}</div>
              <div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
                <label className="field-label">
                  <input
                    type="checkbox"
                    checked={Boolean(globalDelivery.sendProgress)}
                    onChange={(e) => updateGlobalDeliveryFlag('sendProgress', e.target.checked)}
                    style={{ marginRight: 6 }}
                  />
                  {lc.sendProgress}
                </label>
                <label className="field-label">
                  <input
                    type="checkbox"
                    checked={Boolean(globalDelivery.sendToolHints)}
                    onChange={(e) => updateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
                    style={{ marginRight: 6 }}
                  />
                  {lc.sendToolHints}
                </label>
                <LucentIconButton
                  className="btn btn-primary btn-sm icon-btn"
                  disabled={isSavingGlobalDelivery || !selectedBot}
                  onClick={() => void saveGlobalDelivery()}
                  tooltip={lc.saveChannel}
                  aria-label={lc.saveChannel}
                >
                  <Save size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="wizard-channel-list">
              {channels.map((channel, idx) => (
                isDashboardChannel(channel) ? null : (
                <div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
                  <div className="row-between">
                    <strong style={{ textTransform: 'uppercase' }}>{channel.channel_type}</strong>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                      <label className="field-label">
                        <input
                          type="checkbox"
                          checked={channel.is_active}
                          onChange={(e) => updateChannelLocal(idx, { is_active: e.target.checked })}
                          disabled={isDashboardChannel(channel)}
                          style={{ marginRight: 6 }}
                        />
                        {lc.enabled}
                      </label>
                      <LucentIconButton
                        className="btn btn-danger btn-sm wizard-icon-btn"
                        disabled={isDashboardChannel(channel) || isSavingChannel}
                        onClick={() => void removeChannel(channel)}
                        tooltip={lc.remove}
                        aria-label={lc.remove}
                      >
                        <Trash2 size={14} />
                      </LucentIconButton>
                    </div>
                  </div>
                  {renderChannelFields(channel, idx)}
                  <div className="row-between">
                    <span className="field-label">{lc.customChannel}</span>
                    <LucentIconButton
                      className="btn btn-primary btn-sm icon-btn"
                      disabled={isSavingChannel}
                      onClick={() => void saveChannel(channel)}
                      tooltip={lc.saveChannel}
                      aria-label={lc.saveChannel}
                    >
                      <Save size={14} />
                    </LucentIconButton>
                  </div>
                </div>
                )
              ))}
            </div>

            <div className="row-between">
              <select
                className="select"
                value={newChannelType}
                onChange={(e) => setNewChannelType(e.target.value as ChannelType)}
                disabled={addableChannelTypes.length === 0 || isSavingChannel}
              >
                {addableChannelTypes.map((t) => (
                  <option key={t} value={t}>{t}</option>
                ))}
              </select>
              <LucentIconButton
                className="btn btn-secondary btn-sm icon-btn"
                disabled={addableChannelTypes.length === 0 || isSavingChannel}
                onClick={() => void addChannel()}
                tooltip={lc.addChannel}
                aria-label={lc.addChannel}
              >
                <Plus size={14} />
              </LucentIconButton>
            </div>
          </div>
        </div>
      )}

      {showSkillsModal && (
        <div className="modal-mask" onClick={() => setShowSkillsModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.skillsPanel}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowSkillsModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="wizard-channel-list ops-skills-list-scroll">
              {botSkills.length === 0 ? (
                <div className="ops-empty-inline">{t.skillsEmpty}</div>
              ) : (
                botSkills.map((skill) => (
                  <div key={skill.id} className="card wizard-channel-card wizard-channel-compact">
                    <div className="row-between">
                      <div>
                        <strong>{skill.name || skill.id}</strong>
                        <div className="field-label mono">{skill.path}</div>
                        <div className="field-label mono">{String(skill.type || '').toUpperCase()}</div>
                        <div className="field-label">{skill.description || '-'}</div>
                      </div>
                      <LucentIconButton
                        className="btn btn-danger btn-sm wizard-icon-btn"
                        onClick={() => void removeBotSkill(skill)}
                        tooltip={t.removeSkill}
                        aria-label={t.removeSkill}
                      >
                        <Trash2 size={14} />
                      </LucentIconButton>
                    </div>
                  </div>
                ))
              )}
            </div>

            <div className="row-between">
              <input
                ref={skillZipPickerRef}
                type="file"
                accept=".zip,application/zip,application/x-zip-compressed"
                onChange={onPickSkillZip}
                style={{ display: 'none' }}
              />
              <button
                className="btn btn-secondary btn-sm"
                disabled={isSkillUploading}
                onClick={triggerSkillZipUpload}
                title={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
                aria-label={isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
              >
                {isSkillUploading ? <RefreshCw size={14} className="animate-spin" /> : null}
                <span style={{ marginLeft: isSkillUploading ? 6 : 0 }}>
                  {isSkillUploading ? t.uploadingFile : t.uploadZipSkill}
                </span>
              </button>
              <span className="field-label">{t.zipOnlyHint}</span>
            </div>
          </div>
        </div>
      )}

      {showEnvParamsModal && (
        <div className="modal-mask" onClick={() => setShowEnvParamsModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.envParams}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEnvParamsModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="field-label" style={{ marginBottom: 8 }}>{t.envParamsDesc}</div>
            <div className="wizard-channel-list">
              {envEntries.length === 0 ? (
                <div className="ops-empty-inline">{t.noEnvParams}</div>
              ) : (
                envEntries.map(([key, value]) => (
                  <div key={key} className="card wizard-channel-card wizard-channel-compact">
                    <div className="row-between" style={{ alignItems: 'center', gap: 8 }}>
                      <input className="input mono" value={key} readOnly style={{ maxWidth: 280 }} />
                      <input
                        className="input"
                        type={envVisibleByKey[key] ? 'text' : 'password'}
                        value={value}
                        onChange={(e) => upsertEnvParam(key, e.target.value)}
                        placeholder={t.envValue}
                      />
                      <LucentIconButton
                        className="btn btn-secondary btn-sm wizard-icon-btn"
                        onClick={() => setEnvVisibleByKey((prev) => ({ ...prev, [key]: !prev[key] }))}
                        tooltip={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
                        aria-label={envVisibleByKey[key] ? t.hideEnvValue : t.showEnvValue}
                      >
                        {envVisibleByKey[key] ? <EyeOff size={14} /> : <Eye size={14} />}
                      </LucentIconButton>
                      <LucentIconButton
                        className="btn btn-danger btn-sm wizard-icon-btn"
                        onClick={() => removeEnvParam(key)}
                        tooltip={t.removeEnvParam}
                        aria-label={t.removeEnvParam}
                      >
                        <Trash2 size={14} />
                      </LucentIconButton>
                    </div>
                  </div>
                ))
              )}
            </div>

            <div className="row-between">
              <input
                className="input mono"
                value={envDraftKey}
                onChange={(e) => setEnvDraftKey(e.target.value.toUpperCase())}
                placeholder={t.envKey}
              />
              <input
                className="input"
                type={envDraftVisible ? 'text' : 'password'}
                value={envDraftValue}
                onChange={(e) => setEnvDraftValue(e.target.value)}
                placeholder={t.envValue}
              />
              <LucentIconButton
                className="btn btn-secondary btn-sm icon-btn"
                onClick={() => setEnvDraftVisible((v) => !v)}
                tooltip={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
                aria-label={envDraftVisible ? t.hideEnvValue : t.showEnvValue}
              >
                {envDraftVisible ? <EyeOff size={14} /> : <Eye size={14} />}
              </LucentIconButton>
              <LucentIconButton
                className="btn btn-secondary btn-sm icon-btn"
                onClick={() => {
                  const key = String(envDraftKey || '').trim().toUpperCase();
                  if (!key) return;
                  upsertEnvParam(key, envDraftValue);
                  setEnvDraftKey('');
                  setEnvDraftValue('');
                }}
                tooltip={t.addEnvParam}
                aria-label={t.addEnvParam}
              >
                <Plus size={14} />
              </LucentIconButton>
            </div>
            <div className="row-between">
              <span className="field-label">{t.envParamsHint}</span>
              <div style={{ display: 'flex', gap: 8 }}>
                <button className="btn btn-secondary" onClick={() => setShowEnvParamsModal(false)}>{t.cancel}</button>
                <button className="btn btn-primary" onClick={() => void saveBotEnvParams()}>{t.save}</button>
              </div>
            </div>
          </div>
        </div>
      )}

      {showCronModal && (
        <div className="modal-mask" onClick={() => setShowCronModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.cronViewer}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton
                  className="btn btn-secondary btn-sm icon-btn"
                  onClick={() => selectedBot && void loadCronJobs(selectedBot.id)}
                  tooltip={t.cronReload}
                  aria-label={t.cronReload}
                  disabled={cronLoading}
                >
                  <RefreshCw size={14} className={cronLoading ? 'animate-spin' : ''} />
                </LucentIconButton>
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowCronModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            {cronLoading ? (
              <div className="ops-empty-inline">{t.cronLoading}</div>
            ) : cronJobs.length === 0 ? (
              <div className="ops-empty-inline">{t.cronEmpty}</div>
            ) : (
              <div className="ops-cron-list ops-cron-list-scroll">
                {cronJobs.map((job) => {
                  const stopping = cronActionJobId === job.id;
                  const channel = String(job.payload?.channel || '').trim();
                  const to = String(job.payload?.to || '').trim();
                  const target = channel && to ? `${channel}:${to}` : channel || to || '-';
                  return (
                    <div key={job.id} className="ops-cron-item">
                      <div className="ops-cron-main">
                        <div className="ops-cron-name">
                          <Clock3 size={13} />
                          <span>{job.name || job.id}</span>
                        </div>
                        <div className="ops-cron-meta mono">{formatCronSchedule(job, isZh)}</div>
                        <div className="ops-cron-meta mono">
                          {job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toLocaleString() : '-'}
                        </div>
                        <div className="ops-cron-meta mono">
                          {target}
                        </div>
                        <div className="ops-cron-meta">{job.enabled === false ? t.cronDisabled : t.cronEnabled}</div>
                      </div>
                      <div className="ops-cron-actions">
                        <LucentIconButton
                          className="btn btn-danger btn-sm icon-btn"
                          onClick={() => void stopCronJob(job.id)}
                          tooltip={t.cronStop}
                          aria-label={t.cronStop}
                          disabled={stopping || job.enabled === false}
                        >
                          <PowerOff size={13} />
                        </LucentIconButton>
                        <LucentIconButton
                          className="btn btn-danger btn-sm icon-btn"
                          onClick={() => void deleteCronJob(job.id)}
                          tooltip={t.cronDelete}
                          aria-label={t.cronDelete}
                          disabled={stopping}
                        >
                          <Trash2 size={13} />
                        </LucentIconButton>
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      )}

      {showAgentModal && (
        <div className="modal-mask" onClick={() => setShowAgentModal(false)}>
          <div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.agentFiles}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowAgentModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="wizard-agent-layout">
              <div className="agent-tabs-vertical">
                {(['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY'] as AgentTab[]).map((tab) => (
                  <button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => setAgentTab(tab)}>{tab}.md</button>
                ))}
              </div>
              <textarea className="textarea md-area" value={String(editForm[tabMap[agentTab]])} onChange={(e) => setEditForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))} />
            </div>
            <div className="row-between">
              <button className="btn btn-secondary" onClick={() => setShowAgentModal(false)}>{t.cancel}</button>
              <button className="btn btn-primary" disabled={isSaving} onClick={() => void saveBot('agent')}>{t.saveFiles}</button>
            </div>
          </div>
        </div>
      )}

      {showRuntimeActionModal && (
        <div className="modal-mask" onClick={() => setShowRuntimeActionModal(false)}>
          <div className="modal-card modal-preview" onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row modal-title-with-close">
              <div className="modal-title-main">
                <h3>{t.lastAction}</h3>
              </div>
              <div className="modal-title-actions">
                <LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowRuntimeActionModal(false)} tooltip={t.close} aria-label={t.close}>
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className="workspace-preview-body">
              <pre>{runtimeAction}</pre>
            </div>
          </div>
        </div>
      )}

      {workspacePreview && (
        <div className="modal-mask" onClick={closeWorkspacePreview}>
          <div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
            <div className="modal-title-row workspace-preview-header">
              <div className="workspace-preview-header-text">
                <h3>{t.filePreview}</h3>
                <span className="modal-sub mono workspace-preview-path-row">
                  <span>{workspacePreview.path}</span>
                  <LucentIconButton
                    className="workspace-preview-copy-name"
                    onClick={() => void copyWorkspacePreviewPath(workspacePreview.path)}
                    tooltip={isZh ? '复制路径' : 'Copy path'}
                    aria-label={isZh ? '复制路径' : 'Copy path'}
                  >
                    <Copy size={12} />
                  </LucentIconButton>
                </span>
              </div>
              <div className="workspace-preview-header-actions">
                <LucentIconButton
                  className="btn btn-secondary btn-sm icon-btn"
                  onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
                  tooltip={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
                  aria-label={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
                >
                  {workspacePreviewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
                </LucentIconButton>
                <LucentIconButton
                  className="btn btn-secondary btn-sm icon-btn"
                  onClick={closeWorkspacePreview}
                  tooltip={t.close}
                  aria-label={t.close}
                >
                  <X size={14} />
                </LucentIconButton>
              </div>
            </div>
            <div className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}>
              {workspacePreview.isImage ? (
                <img
                  className="workspace-preview-image"
                  src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
                  alt={workspacePreview.path.split('/').pop() || 'workspace-image'}
                />
              ) : workspacePreview.isVideo ? (
                <video
                  className="workspace-preview-media"
                  src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
                  controls
                  preload="metadata"
                />
              ) : workspacePreview.isAudio ? (
                <audio
                  className="workspace-preview-audio"
                  src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
                  controls
                  preload="metadata"
                />
              ) : workspacePreview.isHtml ? (
                <iframe
                  className="workspace-preview-embed"
                  src={buildWorkspaceDownloadHref(workspacePreview.path, false)}
                  title={workspacePreview.path}
                />
              ) : workspacePreview.isMarkdown ? (
                <div className="workspace-markdown">
                  <ReactMarkdown
                    remarkPlugins={[remarkGfm]}
                    rehypePlugins={[rehypeRaw, rehypeSanitize]}
                    components={markdownComponents}
                  >
                    {workspacePreview.content}
                  </ReactMarkdown>
                </div>
              ) : (
                <pre>{workspacePreview.content}</pre>
              )}
            </div>
            {workspacePreview.truncated ? (
              <div className="ops-empty-inline">{t.fileTruncated}</div>
            ) : null}
            <div className="row-between">
              <span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
              <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
                {workspacePreview.isHtml ? (
                  <button
                    className="btn btn-secondary"
                    onClick={() => void copyWorkspacePreviewUrl(workspacePreview.path)}
                  >
                    {t.copyAddress}
                  </button>
                ) : (
                  <a
                    className="btn btn-secondary"
                    href={buildWorkspaceDownloadHref(workspacePreview.path, true)}
                    target="_blank"
                    rel="noopener noreferrer"
                    download={workspacePreview.path.split('/').pop() || 'workspace-file'}
                  >
                    {t.download}
                  </a>
                )}
              </div>
            </div>
          </div>
        </div>
      )}
      {workspaceHoverCard ? (
        <div
          className={`workspace-hover-panel ${workspaceHoverCard.above ? 'is-above' : ''}`}
          style={{ top: workspaceHoverCard.top, left: workspaceHoverCard.left }}
          role="tooltip"
        >
          <div className="workspace-entry-info-row">
            <span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
            <span className="workspace-entry-info-value mono">{workspaceHoverCard.node.name || '-'}</span>
          </div>
          <div className="workspace-entry-info-row">
            <span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
            <span className="workspace-entry-info-value mono">
              {`/root/.nanobot/workspace/${String(workspaceHoverCard.node.path || '').replace(/^\/+/, '')}`}
            </span>
          </div>
          <div className="workspace-entry-info-row">
            <span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
            <span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.ctime, isZh)}</span>
          </div>
          <div className="workspace-entry-info-row">
            <span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
            <span className="workspace-entry-info-value">{formatWorkspaceTime(workspaceHoverCard.node.mtime, isZh)}</span>
          </div>
          <div className="workspace-entry-info-row">
            <span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
            <span className="workspace-entry-info-value mono">{Number.isFinite(Number(workspaceHoverCard.node.size)) ? formatBytes(Number(workspaceHoverCard.node.size)) : '-'}</span>
          </div>
        </div>
      ) : null}
    </>
  );
}
