1639 lines
60 KiB
TypeScript
1639 lines
60 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import axios from 'axios';
|
|
import { Activity, MessageSquareText, X } from 'lucide-react';
|
|
import { APP_ENDPOINTS } from '../../config/env';
|
|
import { useAppStore } from '../../store/appStore';
|
|
import { normalizeAssistantMessageText } from './messageParser';
|
|
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';
|
|
import { TopicFeedPanel } from './topic/TopicFeedPanel';
|
|
import {
|
|
ChatDisabledMask,
|
|
ChatModeRail,
|
|
ConversationViewport,
|
|
} from './components/ChatPanelParts';
|
|
import { ChatComposerDock } from './components/ChatComposerDock';
|
|
import { WorkspaceHoverTooltip, WorkspacePreviewModal } from './components/WorkspacePanelParts';
|
|
import { ResourceMonitorModal } from './components/RuntimePanelParts';
|
|
import { BotDashboardBotListPanel } from './components/BotDashboardBotListPanel';
|
|
import { BotDashboardConfigDrawers } from './components/BotDashboardConfigDrawers';
|
|
import { BotDashboardConversationNodes } from './components/BotDashboardConversationNodes';
|
|
import { BotDashboardRuntimePanel } from './components/BotDashboardRuntimePanel';
|
|
import { isChannelConfigured, renderBotChannelFields } from './components/BotChannelFieldEditor';
|
|
import {
|
|
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
|
RUNTIME_STALE_MS,
|
|
clampTemperature,
|
|
isPreviewableWorkspacePath,
|
|
mergeConversation,
|
|
normalizeRuntimeState,
|
|
parseBotTimestamp,
|
|
topicDraftUiKey,
|
|
resolveWorkspaceDocumentPath,
|
|
workspaceFileAction,
|
|
} from './botDashboardShared';
|
|
import type {
|
|
BotChannel,
|
|
CompactPanelTab,
|
|
NodeBotGroup,
|
|
RuntimeViewMode,
|
|
WorkspaceTreeResponse,
|
|
} from './botDashboardShared';
|
|
import { useDashboardChat } from './hooks/useDashboardChat';
|
|
import { useBotDashboardConfigState } from './hooks/useBotDashboardConfigState';
|
|
import { useDashboardChannelConfig } from './hooks/useDashboardChannelConfig';
|
|
import { useDashboardMcpCronConfig } from './hooks/useDashboardMcpCronConfig';
|
|
import { useDashboardRuntimeControl } from './hooks/useDashboardRuntimeControl';
|
|
import { useDashboardSkillsEnvConfig } from './hooks/useDashboardSkillsEnvConfig';
|
|
import { useDashboardTopicConfig } from './hooks/useDashboardTopicConfig';
|
|
import { useDashboardWorkspace } from './hooks/useDashboardWorkspace';
|
|
import {
|
|
readCachedPlatformPageSize,
|
|
} from '../../utils/platformPageSize';
|
|
|
|
interface BotDashboardModuleProps {
|
|
onOpenCreateWizard?: () => void;
|
|
onOpenImageFactory?: () => void;
|
|
forcedBotId?: string;
|
|
forcedNodeId?: string;
|
|
compactMode?: boolean;
|
|
initialCompactPanelTab?: 'chat' | 'runtime';
|
|
hideCompactFab?: boolean;
|
|
}
|
|
|
|
export function BotDashboardModule({
|
|
onOpenCreateWizard,
|
|
onOpenImageFactory,
|
|
forcedBotId,
|
|
forcedNodeId,
|
|
compactMode = false,
|
|
initialCompactPanelTab = 'chat',
|
|
hideCompactFab = 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 [speechEnabled, setSpeechEnabled] = useState(true);
|
|
const [showChannelModal, setShowChannelModal] = useState(false);
|
|
const [showTopicModal, setShowTopicModal] = useState(false);
|
|
const [showSkillsModal, setShowSkillsModal] = useState(false);
|
|
const [showMcpModal, setShowMcpModal] = useState(false);
|
|
const [showEnvParamsModal, setShowEnvParamsModal] = useState(false);
|
|
const [showCronModal, setShowCronModal] = useState(false);
|
|
const channelCreateMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const topicPresetMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const skillZipPickerRef = useRef<HTMLInputElement | null>(null);
|
|
const skillAddMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const [uploadMaxMb, setUploadMaxMb] = useState(100);
|
|
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState<string[]>([]);
|
|
const [botListPageSize, setBotListPageSize] = useState(() => readCachedPlatformPageSize(10));
|
|
const [botListPageSizeReady, setBotListPageSizeReady] = useState(
|
|
() => readCachedPlatformPageSize(0) > 0,
|
|
);
|
|
const [chatPullPageSize, setChatPullPageSize] = useState(60);
|
|
const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10);
|
|
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
|
|
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
|
);
|
|
const [runtimeViewMode, setRuntimeViewMode] = useState<RuntimeViewMode>('visual');
|
|
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
|
|
const [botListMenuOpen, setBotListMenuOpen] = useState(false);
|
|
const [topicDetailOpen, setTopicDetailOpen] = useState(false);
|
|
const [compactPanelTab, setCompactPanelTab] = useState<CompactPanelTab>(initialCompactPanelTab);
|
|
const [isCompactMobile, setIsCompactMobile] = useState(false);
|
|
const [botListQuery, setBotListQuery] = useState('');
|
|
const [botListPage, setBotListPage] = useState(1);
|
|
const [expandedProgressByKey, setExpandedProgressByKey] = useState<Record<string, boolean>>({});
|
|
const [expandedUserByKey, setExpandedUserByKey] = useState<Record<string, boolean>>({});
|
|
const [showRuntimeActionModal, setShowRuntimeActionModal] = useState(false);
|
|
const botSearchInputName = useMemo(
|
|
() => `nbot-search-${Math.random().toString(36).slice(2, 10)}`,
|
|
[],
|
|
);
|
|
const workspaceSearchInputName = useMemo(
|
|
() => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
|
|
[],
|
|
);
|
|
const runtimeMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const botListMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const botOrderRef = useRef<Record<string, number>>({});
|
|
const nextBotOrderRef = useRef(1);
|
|
const copyWorkspacePreviewUrl = async (filePath: string) => {
|
|
const normalized = String(filePath || '').trim();
|
|
if (!selectedBotId || !normalized) return;
|
|
const hrefRaw = buildWorkspacePreviewHref(normalized);
|
|
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, workspaceDownloadExtensionSet);
|
|
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, workspaceDownloadExtensionSet) || action === 'unsupported') {
|
|
notify(fileNotPreviewableLabel, { tone: 'warning' });
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
|
|
const src = String(srcRaw || '').trim();
|
|
if (!src || !selectedBotId) return src;
|
|
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
|
|
if (resolvedWorkspacePath) {
|
|
return buildWorkspacePreviewHref(resolvedWorkspacePath);
|
|
}
|
|
const lower = src.toLowerCase();
|
|
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
|
|
return src;
|
|
}
|
|
return src;
|
|
}, [selectedBotId]);
|
|
|
|
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)
|
|
.filter((bot) => {
|
|
const expectedNodeId = String(forcedNodeId || '').trim().toLowerCase();
|
|
if (!expectedNodeId) return true;
|
|
return String(bot.node_id || 'local').trim().toLowerCase() === expectedNodeId;
|
|
})
|
|
.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, forcedNodeId],
|
|
);
|
|
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
|
|
const compactListFirstMode = compactMode && !hasForcedBot;
|
|
const isCompactListPage = compactListFirstMode && !selectedBotId;
|
|
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
|
|
const showBotListPanel = !hasForcedBot && (!compactMode || isCompactListPage);
|
|
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();
|
|
const nodeId = String(bot.node_id || '').toLowerCase();
|
|
const nodeName = String(bot.node_display_name || '').toLowerCase();
|
|
return (
|
|
id.includes(normalizedBotListQuery) ||
|
|
name.includes(normalizedBotListQuery) ||
|
|
nodeId.includes(normalizedBotListQuery) ||
|
|
nodeName.includes(normalizedBotListQuery)
|
|
);
|
|
});
|
|
}, [bots, normalizedBotListQuery]);
|
|
const botListTotalPages = Math.max(1, Math.ceil(filteredBots.length / botListPageSize));
|
|
const pagedBots = useMemo(() => {
|
|
const page = Math.min(Math.max(1, botListPage), botListTotalPages);
|
|
const start = (page - 1) * botListPageSize;
|
|
return filteredBots.slice(start, start + botListPageSize);
|
|
}, [filteredBots, botListPage, botListTotalPages, botListPageSize]);
|
|
const pagedBotGroups = useMemo<NodeBotGroup[]>(() => {
|
|
const unknownNodeLabel = locale === 'zh' ? '未命名节点' : 'Unnamed node';
|
|
const groups = new Map<string, NodeBotGroup>();
|
|
pagedBots.forEach((bot) => {
|
|
const nodeId = String(bot.node_id || 'local').trim() || 'local';
|
|
const label = String(bot.node_display_name || '').trim() || nodeId || unknownNodeLabel;
|
|
const key = nodeId.toLowerCase();
|
|
const existing = groups.get(key);
|
|
if (existing) {
|
|
existing.bots.push(bot);
|
|
return;
|
|
}
|
|
groups.set(key, {
|
|
key,
|
|
label,
|
|
nodeId,
|
|
bots: [bot],
|
|
});
|
|
});
|
|
return Array.from(groups.values()).sort((a, b) => {
|
|
if (a.key === b.key) return 0;
|
|
if (a.key === 'local') return -1;
|
|
if (b.key === 'local') return 1;
|
|
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
|
});
|
|
}, [locale, pagedBots]);
|
|
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 refresh = async () => {
|
|
const forced = String(forcedBotId || '').trim();
|
|
if (forced) {
|
|
const targetId = String(selectedBotId || forced).trim() || forced;
|
|
const botRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(targetId)}`);
|
|
setBots(botRes.data ? [botRes.data] : []);
|
|
return;
|
|
}
|
|
|
|
const botsRes = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
|
|
setBots(botsRes.data);
|
|
};
|
|
const {
|
|
addChannel,
|
|
addableChannelTypes,
|
|
beginChannelCreate,
|
|
channelCreateMenuOpen,
|
|
channelDraftUiKey,
|
|
channels,
|
|
expandedChannelByKey,
|
|
globalDelivery,
|
|
isDashboardChannel,
|
|
isSavingChannel,
|
|
isSavingGlobalDelivery,
|
|
newChannelDraft,
|
|
newChannelPanelOpen,
|
|
openChannelModal,
|
|
removeChannel,
|
|
resetChannelCollection,
|
|
resetNewChannelDraft,
|
|
saveChannel,
|
|
saveGlobalDelivery,
|
|
setChannelCreateMenuOpen,
|
|
setExpandedChannelByKey,
|
|
setGlobalDelivery,
|
|
setNewChannelDraft,
|
|
setNewChannelPanelOpen,
|
|
updateChannelLocal,
|
|
updateGlobalDeliveryFlag,
|
|
} = useDashboardChannelConfig({
|
|
confirm,
|
|
notify,
|
|
refresh,
|
|
selectedBot,
|
|
setShowChannelModal,
|
|
t,
|
|
});
|
|
const {
|
|
botSkills,
|
|
envDraftKey,
|
|
envDraftValue,
|
|
envParams,
|
|
installMarketSkill,
|
|
isMarketSkillsLoading,
|
|
isSkillUploading,
|
|
loadBotEnvParams,
|
|
loadBotSkills,
|
|
loadMarketSkills,
|
|
marketSkillInstallingId,
|
|
marketSkills,
|
|
onPickSkillZip,
|
|
removeBotSkill,
|
|
removeEnvParam,
|
|
resetSkillsEnvState,
|
|
saveBotEnvParams,
|
|
setEnvDraftKey,
|
|
setEnvDraftValue,
|
|
setShowSkillMarketInstallModal,
|
|
setSkillAddMenuOpen,
|
|
showSkillMarketInstallModal,
|
|
skillAddMenuOpen,
|
|
triggerSkillZipUpload,
|
|
upsertEnvParam,
|
|
} = useDashboardSkillsEnvConfig({
|
|
confirm,
|
|
isZh,
|
|
notify,
|
|
selectedBot,
|
|
setShowEnvParamsModal,
|
|
skillZipPickerRef,
|
|
t,
|
|
});
|
|
const {
|
|
beginMcpCreate,
|
|
canRemoveMcpServer,
|
|
cronActionJobId,
|
|
cronJobs,
|
|
cronLoading,
|
|
deleteCronJob,
|
|
expandedMcpByKey,
|
|
isSavingMcp,
|
|
loadCronJobs,
|
|
mcpDraftUiKey,
|
|
mcpServers,
|
|
newMcpDraft,
|
|
newMcpPanelOpen,
|
|
openMcpModal,
|
|
removeMcpServer,
|
|
resetCronState,
|
|
resetMcpConfigState,
|
|
resetNewMcpDraft,
|
|
saveNewMcpServer,
|
|
saveSingleMcpServer,
|
|
setExpandedMcpByKey,
|
|
setNewMcpDraft,
|
|
setNewMcpPanelOpen,
|
|
stopCronJob,
|
|
updateMcpServer,
|
|
} = useDashboardMcpCronConfig({
|
|
confirm,
|
|
isZh,
|
|
notify,
|
|
selectedBot,
|
|
setShowMcpModal,
|
|
t,
|
|
});
|
|
const {
|
|
batchStartBots,
|
|
batchStopBots,
|
|
controlStateByBot,
|
|
isBatchOperating,
|
|
loadResourceSnapshot,
|
|
openResourceMonitor,
|
|
operatingBotId,
|
|
resourceBot,
|
|
resourceBotId,
|
|
resourceError,
|
|
resourceLoading,
|
|
resourceSnapshot,
|
|
restartBot,
|
|
setBotEnabled,
|
|
setShowResourceModal,
|
|
showResourceModal,
|
|
startBot,
|
|
stopBot,
|
|
} = useDashboardRuntimeControl({
|
|
bots,
|
|
confirm,
|
|
isZh,
|
|
notify,
|
|
refresh,
|
|
t,
|
|
updateBotStatus,
|
|
});
|
|
const {
|
|
buildWorkspaceDownloadHref,
|
|
buildWorkspacePreviewHref,
|
|
buildWorkspaceRawHref,
|
|
closeWorkspacePreview,
|
|
filteredWorkspaceEntries,
|
|
hideWorkspaceHoverCard,
|
|
loadWorkspaceTree,
|
|
openWorkspaceFilePreview,
|
|
resetWorkspaceBrowserState,
|
|
saveWorkspacePreviewMarkdown,
|
|
setWorkspaceAutoRefresh,
|
|
setWorkspaceHoverCard,
|
|
setWorkspacePreviewDraft,
|
|
setWorkspacePreviewFullscreen,
|
|
setWorkspacePreviewMode,
|
|
setWorkspaceQuery,
|
|
showWorkspaceHoverCard,
|
|
triggerWorkspaceFileDownload,
|
|
workspaceAutoRefresh,
|
|
workspaceCurrentPath,
|
|
workspaceDownloadExtensionSet,
|
|
workspaceError,
|
|
workspaceFileLoading,
|
|
workspaceFiles,
|
|
workspaceHoverCard,
|
|
workspaceLoading,
|
|
workspaceParentPath,
|
|
workspacePathDisplay,
|
|
workspacePreview,
|
|
workspacePreviewCanEdit,
|
|
workspacePreviewDraft,
|
|
workspacePreviewEditorEnabled,
|
|
workspacePreviewFullscreen,
|
|
workspacePreviewSaving,
|
|
workspaceSearchLoading,
|
|
workspaceSearchQuery: workspaceQuery,
|
|
} = useDashboardWorkspace({
|
|
notify,
|
|
selectedBotDockerStatus: selectedBot?.docker_status,
|
|
selectedBotId,
|
|
t,
|
|
workspaceDownloadExtensions,
|
|
});
|
|
const {
|
|
activeTopicOptions,
|
|
addTopic,
|
|
beginTopicCreate,
|
|
deleteTopicFeedItem,
|
|
effectiveTopicPresetTemplates,
|
|
expandedTopicByKey,
|
|
hasTopicUnread,
|
|
isSavingTopic,
|
|
loadTopicFeed,
|
|
loadTopicFeedStats,
|
|
loadTopics,
|
|
markTopicFeedItemRead,
|
|
newTopicAdvancedOpen,
|
|
newTopicDescription,
|
|
newTopicExamplesNegative,
|
|
newTopicExamplesPositive,
|
|
newTopicExcludeWhen,
|
|
newTopicIncludeWhen,
|
|
newTopicKey,
|
|
newTopicName,
|
|
newTopicPanelOpen,
|
|
newTopicPriority,
|
|
newTopicPurpose,
|
|
newTopicSourceLabel,
|
|
normalizeTopicKeyInput,
|
|
openTopicModal,
|
|
removeTopic,
|
|
resetNewTopicDraft,
|
|
resetTopicConfigState,
|
|
resetTopicFeedState,
|
|
saveTopic,
|
|
setExpandedTopicByKey,
|
|
setNewTopicAdvancedOpen,
|
|
setNewTopicDescription,
|
|
setNewTopicExamplesNegative,
|
|
setNewTopicExamplesPositive,
|
|
setNewTopicExcludeWhen,
|
|
setNewTopicIncludeWhen,
|
|
setNewTopicKey,
|
|
setNewTopicName,
|
|
setNewTopicPanelOpen,
|
|
setNewTopicPriority,
|
|
setNewTopicPurpose,
|
|
setTopicFeedTopicKey,
|
|
setTopicPresetMenuOpen,
|
|
setTopicPresetTemplates,
|
|
topicFeedDeleteSavingById,
|
|
topicFeedError,
|
|
topicFeedItems,
|
|
topicFeedLoading,
|
|
topicFeedLoadingMore,
|
|
topicFeedNextCursor,
|
|
topicFeedReadSavingById,
|
|
topicFeedTopicKey,
|
|
topicPanelState,
|
|
topicPresetMenuOpen,
|
|
topics,
|
|
updateTopicLocal,
|
|
} = useDashboardTopicConfig({
|
|
confirm,
|
|
isZh,
|
|
notify,
|
|
selectedBot,
|
|
setShowTopicModal,
|
|
t,
|
|
});
|
|
const passwordToggleLabels = isZh
|
|
? { show: '显示密码', hide: '隐藏密码' }
|
|
: { show: 'Show password', hide: 'Hide password' };
|
|
const lc = isZh ? channelsZhCn : channelsEn;
|
|
const runtimeMoreLabel = isZh ? '更多' : 'More';
|
|
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
|
|
const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false);
|
|
const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
|
|
const conversation = useMemo(() => mergeConversation(messages), [messages]);
|
|
const latestEvent = useMemo(() => [...events].reverse()[0], [events]);
|
|
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 {
|
|
activeControlCommand,
|
|
attachmentUploadPercent,
|
|
canSendControlCommand,
|
|
chatBottomRef,
|
|
chatDateJumping,
|
|
chatDatePanelPosition,
|
|
chatDatePickerOpen,
|
|
chatDateTriggerRef,
|
|
chatDateValue,
|
|
chatScrollRef,
|
|
command,
|
|
composerTextareaRef,
|
|
controlCommandPanelOpen,
|
|
controlCommandPanelRef,
|
|
copyAssistantReply,
|
|
copyUserPrompt,
|
|
editUserPrompt,
|
|
feedbackSavingByMessageId,
|
|
filePickerRef,
|
|
interruptExecution,
|
|
isInterrupting,
|
|
isSendingBlocked,
|
|
isUploadingAttachments,
|
|
isVoiceRecording,
|
|
isVoiceTranscribing,
|
|
jumpConversationToDate,
|
|
loadInitialChatMessages,
|
|
onChatScroll,
|
|
onComposerKeyDown,
|
|
onPickAttachments,
|
|
onVoiceInput,
|
|
pendingAttachments,
|
|
quoteAssistantReply,
|
|
quotedReply,
|
|
send,
|
|
sendControlCommand,
|
|
setChatDatePickerOpen,
|
|
setChatDateValue,
|
|
setCommand,
|
|
setControlCommandPanelOpen,
|
|
setPendingAttachments,
|
|
setQuotedReply,
|
|
setVoiceCountdown,
|
|
setVoiceMaxSeconds,
|
|
showInterruptSubmitAction,
|
|
submitAssistantFeedback,
|
|
syncChatScrollToBottom,
|
|
toggleChatDatePicker,
|
|
triggerPickAttachments,
|
|
voiceCountdown,
|
|
} = useDashboardChat({
|
|
activeBots,
|
|
addBotMessage,
|
|
allowedAttachmentExtensions,
|
|
canChat,
|
|
chatPullPageSize,
|
|
commandAutoUnlockSeconds,
|
|
isThinking,
|
|
isZh,
|
|
loadWorkspaceTree,
|
|
messages,
|
|
notify,
|
|
selectedBot,
|
|
selectedBotId,
|
|
setAllowedAttachmentExtensions,
|
|
setBotMessageFeedback,
|
|
setBotMessages,
|
|
setUploadMaxMb,
|
|
speechEnabled,
|
|
t,
|
|
uploadMaxMb,
|
|
workspaceCurrentPath,
|
|
});
|
|
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
|
|
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 conversationNodes = (
|
|
<BotDashboardConversationNodes
|
|
conversation={conversation}
|
|
expandedProgressByKey={expandedProgressByKey}
|
|
expandedUserByKey={expandedUserByKey}
|
|
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
|
isZh={isZh}
|
|
onCopyAssistantReply={copyAssistantReply}
|
|
onCopyUserPrompt={copyUserPrompt}
|
|
onEditUserPrompt={editUserPrompt}
|
|
onOpenWorkspacePath={(path) => {
|
|
void openWorkspacePathFromChat(path);
|
|
}}
|
|
onQuoteAssistantReply={quoteAssistantReply}
|
|
onResolveMediaSrc={resolveWorkspaceMediaSrc}
|
|
onSubmitAssistantFeedback={submitAssistantFeedback}
|
|
onToggleProgressExpansion={(key) => {
|
|
setExpandedProgressByKey((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}));
|
|
}}
|
|
onToggleUserExpansion={(key) => {
|
|
setExpandedUserByKey((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}));
|
|
}}
|
|
t={t}
|
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
|
/>
|
|
);
|
|
|
|
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 (compactListFirstMode) {
|
|
if (selectedBotId && !activeBots[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, compactListFirstMode]);
|
|
|
|
useEffect(() => {
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (runtimeMenuRef.current && !runtimeMenuRef.current.contains(event.target as Node)) {
|
|
setRuntimeMenuOpen(false);
|
|
}
|
|
if (botListMenuRef.current && !botListMenuRef.current.contains(event.target as Node)) {
|
|
setBotListMenuOpen(false);
|
|
}
|
|
if (controlCommandPanelRef.current && !controlCommandPanelRef.current.contains(event.target as Node)) {
|
|
setChatDatePickerOpen(false);
|
|
}
|
|
if (channelCreateMenuRef.current && !channelCreateMenuRef.current.contains(event.target as Node)) {
|
|
setChannelCreateMenuOpen(false);
|
|
}
|
|
if (topicPresetMenuRef.current && !topicPresetMenuRef.current.contains(event.target as Node)) {
|
|
setTopicPresetMenuOpen(false);
|
|
}
|
|
if (skillAddMenuRef.current && !skillAddMenuRef.current.contains(event.target as Node)) {
|
|
setSkillAddMenuOpen(false);
|
|
}
|
|
};
|
|
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
if (event.key !== 'Escape') return;
|
|
setChatDatePickerOpen(false);
|
|
setChannelCreateMenuOpen(false);
|
|
setTopicPresetMenuOpen(false);
|
|
setSkillAddMenuOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setRuntimeMenuOpen(false);
|
|
setBotListMenuOpen(false);
|
|
}, [selectedBotId]);
|
|
|
|
useEffect(() => {
|
|
setExpandedProgressByKey({});
|
|
setExpandedUserByKey({});
|
|
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 (!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 (compactMode) {
|
|
setCompactPanelTab(initialCompactPanelTab);
|
|
}
|
|
}, [compactMode, initialCompactPanelTab]);
|
|
|
|
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 renderChannelFields = (channel: BotChannel, onPatch: (patch: Partial<BotChannel>) => void) =>
|
|
renderBotChannelFields({
|
|
channel,
|
|
lc,
|
|
onPatch,
|
|
passwordToggleLabels,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!selectedBotId) {
|
|
resetWorkspaceBrowserState();
|
|
resetChannelCollection();
|
|
resetTopicConfigState('full');
|
|
setPendingAttachments([]);
|
|
resetCronState();
|
|
resetSkillsEnvState();
|
|
resetMcpConfigState('full');
|
|
resetTopicFeedState(true);
|
|
return;
|
|
}
|
|
resetTopicConfigState('bot-switch');
|
|
resetMcpConfigState('bot-switch');
|
|
resetTopicFeedState();
|
|
let cancelled = false;
|
|
const loadAll = async () => {
|
|
try {
|
|
if (cancelled) return;
|
|
await loadInitialChatMessages(selectedBotId);
|
|
if (cancelled) return;
|
|
await Promise.all([
|
|
loadWorkspaceTree(selectedBotId, ''),
|
|
loadCronJobs(selectedBotId),
|
|
loadBotSkills(selectedBotId),
|
|
loadBotEnvParams(selectedBotId),
|
|
loadTopics(selectedBotId),
|
|
loadTopicFeedStats(selectedBotId),
|
|
]);
|
|
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
|
|
} 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, loadInitialChatMessages, syncChatScrollToBottom]);
|
|
|
|
useEffect(() => {
|
|
if (!topicFeedTopicKey || topicFeedTopicKey === '__all__') return;
|
|
const exists = activeTopicOptions.some((row) => row.key === topicFeedTopicKey);
|
|
if (!exists) {
|
|
setTopicFeedTopicKey('__all__');
|
|
}
|
|
}, [activeTopicOptions, topicFeedTopicKey]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBotId || runtimeViewMode !== 'topic') return;
|
|
if (topics.length === 0) {
|
|
void loadTopics(selectedBotId);
|
|
}
|
|
}, [runtimeViewMode, selectedBotId, topics.length]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBot || runtimeViewMode !== 'topic') return;
|
|
void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBot || runtimeViewMode !== 'topic') return;
|
|
if (topicDetailOpen) return;
|
|
const timer = window.setInterval(() => {
|
|
void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey });
|
|
}, 15000);
|
|
return () => window.clearInterval(timer);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedBot?.id, runtimeViewMode, topicFeedTopicKey, topicDetailOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedBotId) return;
|
|
void loadTopicFeedStats(selectedBotId);
|
|
const timer = window.setInterval(() => {
|
|
void loadTopicFeedStats(selectedBotId);
|
|
}, 15000);
|
|
return () => window.clearInterval(timer);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedBotId]);
|
|
|
|
const {
|
|
agentTab,
|
|
applyEditFormFromBot,
|
|
defaultSystemTimezone,
|
|
editForm,
|
|
ensureSelectedBotDetail,
|
|
isSaving,
|
|
isTestingProvider,
|
|
onBaseProviderChange,
|
|
paramDraft,
|
|
providerTestResult,
|
|
saveBot,
|
|
setAgentTab,
|
|
setEditForm,
|
|
setParamDraft,
|
|
setProviderTestResult,
|
|
setShowAgentModal,
|
|
setShowBaseModal,
|
|
setShowParamModal,
|
|
showAgentModal,
|
|
showBaseModal,
|
|
showParamModal,
|
|
systemTimezoneOptions,
|
|
testProviderConnection,
|
|
} = useBotDashboardConfigState({
|
|
isZh,
|
|
mergeBot,
|
|
notify,
|
|
refresh,
|
|
selectedBot,
|
|
selectedBotId,
|
|
setAllowedAttachmentExtensions,
|
|
setBotListPageSize,
|
|
setBotListPageSizeReady,
|
|
setChatPullPageSize,
|
|
setCommandAutoUnlockSeconds,
|
|
setSpeechEnabled,
|
|
setTopicPresetTemplates,
|
|
setUploadMaxMb,
|
|
setVoiceCountdown,
|
|
setVoiceMaxSeconds,
|
|
setWorkspaceDownloadExtensions,
|
|
t,
|
|
});
|
|
|
|
|
|
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' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''} ${hasForcedBot && !compactMode ? 'grid-ops-forced' : ''}`}>
|
|
{showBotListPanel ? (
|
|
<BotDashboardBotListPanel
|
|
botCount={bots.length}
|
|
botListMenuOpen={botListMenuOpen}
|
|
botListMenuRef={botListMenuRef}
|
|
botListPage={botListPage}
|
|
botListPageSizeReady={botListPageSizeReady}
|
|
botListQuery={botListQuery}
|
|
botListTotalPages={botListTotalPages}
|
|
botSearchInputName={botSearchInputName}
|
|
controlStateByBot={controlStateByBot}
|
|
filteredBotCount={filteredBots.length}
|
|
isBatchOperating={isBatchOperating}
|
|
isZh={isZh}
|
|
normalizedBotListQuery={normalizedBotListQuery}
|
|
onBatchStartBots={() => {
|
|
setBotListMenuOpen(false);
|
|
void batchStartBots();
|
|
}}
|
|
onBatchStopBots={() => {
|
|
setBotListMenuOpen(false);
|
|
void batchStopBots();
|
|
}}
|
|
onNextPage={() => setBotListPage((page) => Math.min(botListTotalPages, page + 1))}
|
|
onOpenCreateWizard={onOpenCreateWizard}
|
|
onOpenImageFactory={onOpenImageFactory}
|
|
onOpenResourceMonitor={openResourceMonitor}
|
|
onPrevPage={() => setBotListPage((page) => Math.max(1, page - 1))}
|
|
onQueryChange={(value) => {
|
|
setBotListQuery(value);
|
|
setBotListPage(1);
|
|
}}
|
|
onRemoveBot={(botId) => {
|
|
void removeBot(botId);
|
|
}}
|
|
onSelectBot={(botId) => {
|
|
setSelectedBotId(botId);
|
|
if (compactMode) setCompactPanelTab('chat');
|
|
}}
|
|
onSetBotEnabled={(botId, enabled) => {
|
|
void setBotEnabled(botId, enabled);
|
|
}}
|
|
onToggleBotListMenu={() => setBotListMenuOpen((prev) => !prev)}
|
|
onToggleBotRunning={(botId, dockerStatus) => {
|
|
void (String(dockerStatus || '').toUpperCase() === 'RUNNING'
|
|
? stopBot(botId, dockerStatus)
|
|
: startBot(botId, dockerStatus));
|
|
}}
|
|
operatingBotId={operatingBotId}
|
|
pagedBotGroups={pagedBotGroups}
|
|
selectedBotId={selectedBotId}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
|
|
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
|
|
{selectedBot ? (
|
|
<div className="ops-chat-shell">
|
|
<div className="ops-main-content-shell">
|
|
<div className="ops-main-content-frame">
|
|
<div className="ops-main-content-head">
|
|
<ChatModeRail
|
|
hasTopicUnread={hasTopicUnread}
|
|
isZh={isZh}
|
|
runtimeViewMode={runtimeViewMode}
|
|
onChange={setRuntimeViewMode}
|
|
/>
|
|
</div>
|
|
<div className="ops-main-content-body">
|
|
{runtimeViewMode === 'topic' ? (
|
|
<TopicFeedPanel
|
|
isZh={isZh}
|
|
topicKey={topicFeedTopicKey}
|
|
topicOptions={activeTopicOptions}
|
|
topicState={topicPanelState}
|
|
items={topicFeedItems}
|
|
loading={topicFeedLoading}
|
|
loadingMore={topicFeedLoadingMore}
|
|
nextCursor={topicFeedNextCursor}
|
|
error={topicFeedError}
|
|
readSavingById={topicFeedReadSavingById}
|
|
deleteSavingById={topicFeedDeleteSavingById}
|
|
onTopicChange={setTopicFeedTopicKey}
|
|
onRefresh={() => void loadTopicFeed({ append: false, topicKey: topicFeedTopicKey })}
|
|
onMarkRead={(itemId) => void markTopicFeedItemRead(itemId)}
|
|
onDeleteItem={(item) => void deleteTopicFeedItem(item)}
|
|
onLoadMore={() => void loadTopicFeed({ append: true, cursor: topicFeedNextCursor, topicKey: topicFeedTopicKey })}
|
|
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
|
onOpenTopicSettings={() => {
|
|
if (selectedBot) openTopicModal(selectedBot.id);
|
|
}}
|
|
onDetailOpenChange={setTopicDetailOpen}
|
|
layout="panel"
|
|
/>
|
|
) : (
|
|
<>
|
|
<ConversationViewport
|
|
bottomRef={chatBottomRef}
|
|
chatScrollRef={chatScrollRef}
|
|
conversationNodes={conversationNodes}
|
|
emptyLabel={t.noConversation}
|
|
hasConversation={conversation.length > 0}
|
|
isChatEnabled={isChatEnabled}
|
|
isThinking={isThinking}
|
|
onScroll={onChatScroll}
|
|
thinkingLabel={t.thinking}
|
|
/>
|
|
<ChatComposerDock
|
|
activeControlCommand={activeControlCommand}
|
|
allowedAttachmentExtensions={allowedAttachmentExtensions}
|
|
attachmentUploadPercent={attachmentUploadPercent}
|
|
canChat={canChat}
|
|
canSendControlCommand={canSendControlCommand}
|
|
chatDateJumping={chatDateJumping}
|
|
chatDatePanelPosition={chatDatePanelPosition}
|
|
chatDatePickerOpen={chatDatePickerOpen}
|
|
chatDateTriggerRef={chatDateTriggerRef}
|
|
chatDateValue={chatDateValue}
|
|
command={command}
|
|
composerTextareaRef={composerTextareaRef}
|
|
controlCommandPanelOpen={controlCommandPanelOpen}
|
|
controlCommandPanelRef={controlCommandPanelRef}
|
|
disabledSend={
|
|
showInterruptSubmitAction
|
|
? isInterrupting
|
|
: (
|
|
!isChatEnabled
|
|
|| isVoiceRecording
|
|
|| isVoiceTranscribing
|
|
|| (!command.trim() && pendingAttachments.length === 0 && !quotedReply)
|
|
)
|
|
}
|
|
filePickerRef={filePickerRef}
|
|
isCompactMobile={isCompactMobile}
|
|
isInterrupting={isInterrupting}
|
|
isUploadingAttachments={isUploadingAttachments}
|
|
isVoiceRecording={isVoiceRecording}
|
|
isVoiceTranscribing={isVoiceTranscribing}
|
|
isZh={isZh}
|
|
onClearQuotedReply={() => setQuotedReply(null)}
|
|
onCommandChange={setCommand}
|
|
onCloseChatDatePicker={() => setChatDatePickerOpen(false)}
|
|
onComposerKeyDown={onComposerKeyDown}
|
|
onDateValueChange={setChatDateValue}
|
|
onInterruptExecution={() => void interruptExecution()}
|
|
onJumpConversationToDate={() => void jumpConversationToDate()}
|
|
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
|
onPickAttachments={onPickAttachments}
|
|
onRemovePendingAttachment={(path) => setPendingAttachments((prev) => prev.filter((v) => v !== path))}
|
|
onSend={() => void (showInterruptSubmitAction ? interruptExecution() : send())}
|
|
onSendControlCommand={(value) => void sendControlCommand(value)}
|
|
onToggleChatDatePicker={toggleChatDatePicker}
|
|
onToggleControlPanel={() => {
|
|
setChatDatePickerOpen(false);
|
|
setControlCommandPanelOpen((prev) => !prev);
|
|
}}
|
|
onTriggerPickAttachments={triggerPickAttachments}
|
|
onVoiceInput={onVoiceInput}
|
|
pendingAttachments={pendingAttachments}
|
|
quotedReply={quotedReply}
|
|
selectedBotId={selectedBotId}
|
|
showInterruptSubmitAction={showInterruptSubmitAction}
|
|
t={t}
|
|
voiceCountdown={voiceCountdown}
|
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
|
/>
|
|
<ChatDisabledMask
|
|
botDisabledHint={t.botDisabledHint}
|
|
botStarting={t.botStarting}
|
|
botStopping={t.botStopping}
|
|
canChat={canChat}
|
|
chatDisabled={t.chatDisabled}
|
|
selectedBotControlState={selectedBotControlState}
|
|
selectedBotEnabled={selectedBotEnabled}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ color: 'var(--muted)' }}>
|
|
{forcedBotMissing
|
|
? `${t.selectBot}: ${String(forcedBotId).trim()}`
|
|
: t.selectBot}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<BotDashboardRuntimePanel
|
|
compactMode={compactMode}
|
|
compactPanelTab={compactPanelTab}
|
|
displayState={displayState}
|
|
filteredWorkspaceEntries={filteredWorkspaceEntries}
|
|
forcedBotId={forcedBotId}
|
|
forcedBotMissing={forcedBotMissing}
|
|
hideWorkspaceHoverCard={hideWorkspaceHoverCard}
|
|
isCompactListPage={isCompactListPage}
|
|
lc={lc}
|
|
loadBotEnvParams={loadBotEnvParams}
|
|
loadBotSkills={loadBotSkills}
|
|
loadCronJobs={loadCronJobs}
|
|
loadWorkspaceTree={loadWorkspaceTree}
|
|
onClearConversationHistory={() => {
|
|
void clearConversationHistory();
|
|
}}
|
|
onEnsureSelectedBotDetail={async () => {
|
|
const detail = await ensureSelectedBotDetail();
|
|
applyEditFormFromBot(detail);
|
|
return detail;
|
|
}}
|
|
onExportConversationJson={exportConversationJson}
|
|
onOpenChannelModal={openChannelModal}
|
|
onOpenMcpModal={(botId) => {
|
|
void openMcpModal(botId);
|
|
}}
|
|
onOpenTopicModal={openTopicModal}
|
|
onOpenWorkspaceFilePreview={(path) => {
|
|
void openWorkspaceFilePreview(path);
|
|
}}
|
|
onRestartBot={(botId, dockerStatus) => {
|
|
void restartBot(botId, dockerStatus);
|
|
}}
|
|
onSetRuntimeMenuOpen={setRuntimeMenuOpen}
|
|
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
|
|
operatingBotId={operatingBotId}
|
|
runtimeMenuOpen={runtimeMenuOpen}
|
|
runtimeMenuRef={runtimeMenuRef}
|
|
runtimeMoreLabel={runtimeMoreLabel}
|
|
selectedBot={selectedBot}
|
|
selectedBotEnabled={selectedBotEnabled}
|
|
selectedBotId={selectedBotId}
|
|
setProviderTestResult={setProviderTestResult}
|
|
setShowAgentModal={setShowAgentModal}
|
|
setShowBaseModal={setShowBaseModal}
|
|
setShowCronModal={setShowCronModal}
|
|
setShowEnvParamsModal={setShowEnvParamsModal}
|
|
setShowParamModal={setShowParamModal}
|
|
setShowSkillsModal={setShowSkillsModal}
|
|
setWorkspaceAutoRefresh={setWorkspaceAutoRefresh}
|
|
setWorkspaceQuery={setWorkspaceQuery}
|
|
showCompactBotPageClose={showCompactBotPageClose}
|
|
t={t}
|
|
workspaceAutoRefresh={workspaceAutoRefresh}
|
|
workspaceCurrentPath={workspaceCurrentPath}
|
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
|
workspaceError={workspaceError}
|
|
workspaceFileLoading={workspaceFileLoading}
|
|
workspaceFiles={workspaceFiles}
|
|
workspaceLoading={workspaceLoading}
|
|
workspaceParentPath={workspaceParentPath}
|
|
workspacePathDisplay={workspacePathDisplay}
|
|
workspaceQuery={workspaceQuery}
|
|
workspaceSearchInputName={workspaceSearchInputName}
|
|
workspaceSearchLoading={workspaceSearchLoading}
|
|
/>
|
|
</div>
|
|
{showCompactBotPageClose ? (
|
|
<LucentIconButton
|
|
className="ops-compact-close-btn"
|
|
onClick={() => {
|
|
setSelectedBotId('');
|
|
setCompactPanelTab('chat');
|
|
}}
|
|
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
|
|
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
|
|
>
|
|
<X size={16} />
|
|
</LucentIconButton>
|
|
) : null}
|
|
{compactMode && !isCompactListPage && !hideCompactFab ? (
|
|
<div className="ops-compact-fab-stack">
|
|
<LucentIconButton
|
|
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
|
|
onClick={() => setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))}
|
|
tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
|
|
aria-label={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
|
|
>
|
|
{compactPanelTab === 'runtime' ? <MessageSquareText size={18} /> : <Activity size={18} />}
|
|
</LucentIconButton>
|
|
</div>
|
|
) : null}
|
|
|
|
<ResourceMonitorModal
|
|
botId={resourceBotId}
|
|
botImageTag={resourceBot?.image_tag || '-'}
|
|
botModel={resourceBot?.llm_model || '-'}
|
|
botName={resourceBot?.name || ''}
|
|
botProvider={resourceBot?.llm_provider || '-'}
|
|
error={resourceError}
|
|
isOpen={showResourceModal}
|
|
labels={{
|
|
baseImage: isZh ? '基础镜像' : 'Base Image',
|
|
blockIo: isZh ? '磁盘 I/O' : 'Block I/O',
|
|
close: t.close,
|
|
collected: isZh ? '采样时间' : 'Collected',
|
|
configuredLimits: isZh ? '配置配额' : 'Configured Limits',
|
|
container: isZh ? '容器状态' : 'Container',
|
|
containerName: isZh ? '容器名' : 'Container Name',
|
|
cpu: 'CPU',
|
|
dockerRuntimeLimits: isZh ? 'Docker 实际限制' : 'Docker Runtime Limits',
|
|
liveUsage: isZh ? '实时使用' : 'Live Usage',
|
|
loading: isZh ? '读取中...' : 'Loading...',
|
|
memory: isZh ? '内存' : 'Memory',
|
|
memoryPercent: isZh ? '内存占比' : 'Memory %',
|
|
networkIo: isZh ? '网络 I/O' : 'Network I/O',
|
|
noMetrics: isZh ? '暂无监控数据' : 'No metrics',
|
|
policy: isZh ? '策略说明' : 'Policy',
|
|
providerModel: 'Provider/Model',
|
|
refreshNow: isZh ? '立即刷新' : 'Refresh now',
|
|
resourceMonitor: isZh ? '资源监测' : 'Resource Monitor',
|
|
sizeUnlimited: isZh ? '不限' : 'Unlimited',
|
|
storage: isZh ? '存储' : 'Storage',
|
|
uiRuleHint: isZh ? '(界面规则:资源配置填写 0 表示不限制)' : ' (UI rule: value 0 means unlimited)',
|
|
workspacePercent: isZh ? '工作区占比' : 'Workspace %',
|
|
workspaceUsage: isZh ? '工作区占用' : 'Workspace Usage',
|
|
}}
|
|
loading={resourceLoading}
|
|
onClose={() => setShowResourceModal(false)}
|
|
onRefresh={() => void loadResourceSnapshot(resourceBotId)}
|
|
snapshot={resourceSnapshot}
|
|
/>
|
|
<BotDashboardConfigDrawers
|
|
isZh={isZh}
|
|
t={t}
|
|
lc={lc}
|
|
noteLocale={noteLocale}
|
|
passwordToggleLabels={passwordToggleLabels}
|
|
defaultSystemTimezone={defaultSystemTimezone}
|
|
systemTimezoneOptions={systemTimezoneOptions}
|
|
selectedBotId={selectedBot?.id || ''}
|
|
selectedBotExists={Boolean(selectedBot)}
|
|
editForm={editForm}
|
|
setEditForm={setEditForm}
|
|
paramDraft={paramDraft}
|
|
setParamDraft={setParamDraft}
|
|
agentTab={agentTab}
|
|
setAgentTab={setAgentTab}
|
|
isSaving={isSaving}
|
|
saveBot={saveBot}
|
|
showBaseModal={showBaseModal}
|
|
setShowBaseModal={setShowBaseModal}
|
|
showParamModal={showParamModal}
|
|
setShowParamModal={setShowParamModal}
|
|
isTestingProvider={isTestingProvider}
|
|
providerTestResult={providerTestResult}
|
|
onBaseProviderChange={onBaseProviderChange}
|
|
onTestProviderConnection={testProviderConnection}
|
|
onTemperatureChange={(value) => setEditForm((prev) => ({ ...prev, temperature: clampTemperature(value) }))}
|
|
showChannelModal={showChannelModal}
|
|
setShowChannelModal={setShowChannelModal}
|
|
channelCreateMenuOpen={channelCreateMenuOpen}
|
|
setChannelCreateMenuOpen={setChannelCreateMenuOpen}
|
|
channelCreateMenuRef={channelCreateMenuRef}
|
|
addableChannelTypes={addableChannelTypes}
|
|
channels={channels}
|
|
expandedChannelByKey={expandedChannelByKey}
|
|
setExpandedChannelByKey={setExpandedChannelByKey}
|
|
globalDelivery={globalDelivery}
|
|
isSavingChannel={isSavingChannel}
|
|
isSavingGlobalDelivery={isSavingGlobalDelivery}
|
|
newChannelDraft={newChannelDraft}
|
|
setNewChannelDraft={setNewChannelDraft}
|
|
newChannelPanelOpen={newChannelPanelOpen}
|
|
setNewChannelPanelOpen={setNewChannelPanelOpen}
|
|
resetNewChannelDraft={resetNewChannelDraft}
|
|
addChannel={addChannel}
|
|
saveChannel={saveChannel}
|
|
saveGlobalDelivery={saveGlobalDelivery}
|
|
updateChannelLocal={updateChannelLocal}
|
|
updateGlobalDeliveryFlag={updateGlobalDeliveryFlag}
|
|
removeChannel={removeChannel}
|
|
renderChannelFields={renderChannelFields}
|
|
isChannelConfigured={isChannelConfigured}
|
|
channelDraftUiKey={channelDraftUiKey}
|
|
isDashboardChannel={isDashboardChannel}
|
|
beginChannelCreate={beginChannelCreate}
|
|
showTopicModal={showTopicModal}
|
|
setShowTopicModal={setShowTopicModal}
|
|
effectiveTopicPresetTemplates={effectiveTopicPresetTemplates}
|
|
topicPresetMenuOpen={topicPresetMenuOpen}
|
|
setTopicPresetMenuOpen={setTopicPresetMenuOpen}
|
|
topicPresetMenuRef={topicPresetMenuRef}
|
|
beginTopicCreate={beginTopicCreate}
|
|
expandedTopicByKey={expandedTopicByKey}
|
|
setExpandedTopicByKey={setExpandedTopicByKey}
|
|
isSavingTopic={isSavingTopic}
|
|
topics={topics}
|
|
newTopicAdvancedOpen={newTopicAdvancedOpen}
|
|
setNewTopicAdvancedOpen={setNewTopicAdvancedOpen}
|
|
newTopicDescription={newTopicDescription}
|
|
setNewTopicDescription={setNewTopicDescription}
|
|
newTopicExamplesNegative={newTopicExamplesNegative}
|
|
setNewTopicExamplesNegative={setNewTopicExamplesNegative}
|
|
newTopicExamplesPositive={newTopicExamplesPositive}
|
|
setNewTopicExamplesPositive={setNewTopicExamplesPositive}
|
|
newTopicExcludeWhen={newTopicExcludeWhen}
|
|
setNewTopicExcludeWhen={setNewTopicExcludeWhen}
|
|
newTopicIncludeWhen={newTopicIncludeWhen}
|
|
setNewTopicIncludeWhen={setNewTopicIncludeWhen}
|
|
newTopicKey={newTopicKey}
|
|
setNewTopicKey={setNewTopicKey}
|
|
newTopicName={newTopicName}
|
|
setNewTopicName={setNewTopicName}
|
|
newTopicPanelOpen={newTopicPanelOpen}
|
|
setNewTopicPanelOpen={setNewTopicPanelOpen}
|
|
newTopicPriority={newTopicPriority}
|
|
setNewTopicPriority={setNewTopicPriority}
|
|
newTopicPurpose={newTopicPurpose}
|
|
setNewTopicPurpose={setNewTopicPurpose}
|
|
newTopicSourceLabel={newTopicSourceLabel}
|
|
resetNewTopicDraft={resetNewTopicDraft}
|
|
addTopic={addTopic}
|
|
normalizeTopicKeyInput={normalizeTopicKeyInput}
|
|
removeTopic={removeTopic}
|
|
saveTopic={saveTopic}
|
|
updateTopicLocal={updateTopicLocal}
|
|
topicDraftUiKey={topicDraftUiKey}
|
|
showSkillsModal={showSkillsModal}
|
|
setShowSkillsModal={setShowSkillsModal}
|
|
skillZipPickerRef={skillZipPickerRef}
|
|
skillAddMenuOpen={skillAddMenuOpen}
|
|
setSkillAddMenuOpen={setSkillAddMenuOpen}
|
|
isSkillUploading={isSkillUploading}
|
|
skillAddMenuRef={skillAddMenuRef}
|
|
onPickSkillZip={onPickSkillZip}
|
|
triggerSkillZipUpload={triggerSkillZipUpload}
|
|
botSkills={botSkills}
|
|
removeBotSkill={removeBotSkill}
|
|
showSkillMarketInstallModal={showSkillMarketInstallModal}
|
|
setShowSkillMarketInstallModal={setShowSkillMarketInstallModal}
|
|
marketSkills={marketSkills}
|
|
isMarketSkillsLoading={isMarketSkillsLoading}
|
|
marketSkillInstallingId={marketSkillInstallingId}
|
|
loadMarketSkills={loadMarketSkills}
|
|
loadBotSkills={loadBotSkills}
|
|
installMarketSkill={installMarketSkill}
|
|
showMcpModal={showMcpModal}
|
|
setShowMcpModal={setShowMcpModal}
|
|
newMcpPanelOpen={newMcpPanelOpen}
|
|
setNewMcpPanelOpen={setNewMcpPanelOpen}
|
|
newMcpDraft={newMcpDraft}
|
|
setNewMcpDraft={setNewMcpDraft}
|
|
resetNewMcpDraft={resetNewMcpDraft}
|
|
beginMcpCreate={beginMcpCreate}
|
|
canRemoveMcpServer={canRemoveMcpServer}
|
|
expandedMcpByKey={expandedMcpByKey}
|
|
setExpandedMcpByKey={setExpandedMcpByKey}
|
|
isSavingMcp={isSavingMcp}
|
|
removeMcpServer={removeMcpServer}
|
|
saveSingleMcpServer={saveSingleMcpServer}
|
|
updateMcpServer={updateMcpServer}
|
|
mcpDraftUiKey={mcpDraftUiKey}
|
|
mcpServers={mcpServers}
|
|
saveNewMcpServer={saveNewMcpServer}
|
|
showEnvParamsModal={showEnvParamsModal}
|
|
setShowEnvParamsModal={setShowEnvParamsModal}
|
|
envDraftKey={envDraftKey}
|
|
setEnvDraftKey={setEnvDraftKey}
|
|
envDraftValue={envDraftValue}
|
|
setEnvDraftValue={setEnvDraftValue}
|
|
envEntries={envEntries}
|
|
upsertEnvParam={upsertEnvParam}
|
|
removeEnvParam={removeEnvParam}
|
|
saveBotEnvParams={saveBotEnvParams}
|
|
showCronModal={showCronModal}
|
|
setShowCronModal={setShowCronModal}
|
|
cronActionJobId={cronActionJobId}
|
|
cronJobs={cronJobs}
|
|
cronLoading={cronLoading}
|
|
loadCronJobs={loadCronJobs}
|
|
deleteCronJob={deleteCronJob}
|
|
stopCronJob={stopCronJob}
|
|
showAgentModal={showAgentModal}
|
|
setShowAgentModal={setShowAgentModal}
|
|
/>
|
|
|
|
{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 ? (
|
|
<WorkspacePreviewModal
|
|
buildWorkspaceDownloadHref={buildWorkspaceDownloadHref}
|
|
buildWorkspaceRawHref={buildWorkspaceRawHref}
|
|
closeLabel={t.close}
|
|
closePreview={closeWorkspacePreview}
|
|
copyAddressLabel={t.copyAddress}
|
|
copyPathLabel={isZh ? '复制路径' : 'Copy path'}
|
|
downloadLabel={t.download}
|
|
editFileLabel={t.editFile}
|
|
filePreviewLabel={t.filePreview}
|
|
fileTruncatedLabel={t.fileTruncated}
|
|
fullscreenEditLabel={isZh ? '全屏编辑' : 'Full screen editor'}
|
|
fullscreenExitLabel={isZh ? '退出全屏' : 'Exit full screen'}
|
|
fullscreenPreviewLabel={isZh ? '全屏预览' : 'Full screen'}
|
|
isZh={isZh}
|
|
onCopyPreviewPath={(path) => void copyWorkspacePreviewPath(path)}
|
|
onCopyPreviewUrl={(path) => void copyWorkspacePreviewUrl(path)}
|
|
onOpenWorkspacePath={(path) => void openWorkspacePathFromChat(path)}
|
|
onSaveShortcut={() => {
|
|
void saveWorkspacePreviewMarkdown();
|
|
}}
|
|
preview={workspacePreview}
|
|
previewCanEdit={workspacePreviewCanEdit}
|
|
previewDraft={workspacePreviewDraft}
|
|
previewEditorEnabled={workspacePreviewEditorEnabled}
|
|
previewFullscreen={workspacePreviewFullscreen}
|
|
previewSaving={workspacePreviewSaving}
|
|
resolveMediaSrc={resolveWorkspaceMediaSrc}
|
|
saveLabel={t.save}
|
|
setPreviewDraft={setWorkspacePreviewDraft}
|
|
setPreviewFullscreen={setWorkspacePreviewFullscreen}
|
|
setPreviewMode={setWorkspacePreviewMode}
|
|
/>
|
|
) : null}
|
|
{workspaceHoverCard ? <WorkspaceHoverTooltip hoverCard={workspaceHoverCard} isZh={isZh} /> : null}
|
|
</>
|
|
);
|
|
}
|