v0.1.4-p4

main
mula.liu 2026-03-31 14:56:31 +08:00
parent c8d11ff654
commit d196e57804
25 changed files with 52 additions and 1039 deletions

View File

@ -195,6 +195,8 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
runtime = _read_bot_runtime_snapshot(bot)
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
return {
"id": bot.id,
"name": bot.name,
@ -226,11 +228,13 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"created_at": bot.created_at,
"updated_at": bot.updated_at,
"created_at": created_at,
"updated_at": updated_at,
}
def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
created_at = bot.created_at.isoformat() + "Z" if bot.created_at else None
updated_at = bot.updated_at.isoformat() + "Z" if bot.updated_at else None
return {
"id": bot.id,
"name": bot.name,
@ -240,7 +244,8 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"updated_at": bot.updated_at,
"created_at": created_at,
"updated_at": updated_at,
}
def _sync_workspace_channels(

View File

@ -2,10 +2,10 @@ from typing import Optional
from core.cache import cache
def _cache_key_bots_list() -> str:
return "bot:list:v2"
return "bot:list:v3"
def _cache_key_bot_detail(bot_id: str) -> str:
return f"bot:detail:v2:{bot_id}"
return f"bot:detail:v3:{bot_id}"
def _cache_key_bot_messages(bot_id: str, limit: int) -> str:
return f"bot:messages:list:v2:{bot_id}:limit:{limit}"

View File

@ -1,4 +1,4 @@
from typing import Any, Dict, List
from typing import Any, Dict
from sqlmodel import Session, select
@ -14,7 +14,9 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
deleted = prune_expired_activity_events(session, force=False)
if deleted > 0:
session.commit()
bots = session.exec(select(BotInstance)).all()
bots = session.exec(
select(BotInstance).order_by(BotInstance.created_at.desc(), BotInstance.id.asc())
).all()
images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all()
settings = get_platform_settings(session)
@ -30,7 +32,6 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
live_memory_used_total = 0
live_memory_limit_total = 0
bot_rows: List[Dict[str, Any]] = []
for bot in bots:
enabled = bool(getattr(bot, "enabled", True))
runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED")
@ -60,23 +61,6 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
else:
stopped += 1
bot_rows.append(
{
"id": bot.id,
"name": bot.name,
"enabled": enabled,
"docker_status": runtime_status,
"image_tag": bot.image_tag,
"llm_provider": getattr(bot, "llm_provider", None),
"llm_model": getattr(bot, "llm_model", None),
"current_state": bot.current_state,
"last_action": bot.last_action,
"resources": resources,
"workspace_usage_bytes": workspace_used,
"workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None,
}
)
usage = list_usage(session, limit=20)
events = list_activity_events(session, limit=20)
@ -114,7 +98,6 @@ def build_platform_overview(session: Session, docker_manager: Any) -> Dict[str,
}
for row in images
],
"bots": bot_rows,
"settings": settings.model_dump(),
"usage": usage,
"events": events,

View File

@ -1 +0,0 @@
export { ManagementModule as BotManagementView } from '../modules/management/ManagementModule';

View File

@ -1 +0,0 @@
export { CreateBotModal } from '../modules/management/components/CreateBotModal';

View File

@ -1 +0,0 @@
export { KernelManagerModal } from '../modules/management/components/KernelManagerModal';

View File

@ -1 +0,0 @@
export { DashboardModule as VisualDeckView } from '../modules/dashboard/DashboardModule';

View File

@ -1 +0,0 @@
export { VoxelBot } from '../modules/dashboard/components/VoxelBot';

View File

@ -1 +0,0 @@
export { WorkingDeck } from '../modules/dashboard/components/WorkingDeck';

View File

@ -1,11 +0,0 @@
export const legacyDeckEn = {
operationsDeck: 'OPERATIONS DECK',
sandbox: 'Neural Network Sandbox',
activeNodes: 'ACTIVE NODES',
globalEvents: 'Global Events',
listening: 'listening for commands',
standby: 'All units in standby mode. Waiting for deployment...',
deckTitle: 'Digital Employee Deck',
deckSub: 'Realtime bot execution and data flow visibility',
idle: 'IDLE',
};

View File

@ -1,11 +0,0 @@
export const legacyDeckZhCn = {
operationsDeck: '运维控制台',
sandbox: '神经网络沙盒',
activeNodes: '活跃节点',
globalEvents: '全局事件',
listening: '等待指令',
standby: '所有单元待命中,等待部署...',
deckTitle: '数字员工工位',
deckSub: '实时观察机器人执行状态与数据流转',
idle: 'IDLE',
};

View File

@ -5,3 +5,16 @@ body,
width: 100%;
min-height: 100vh;
}
.animate-spin {
animation: app-spin 1s linear infinite;
}
@keyframes app-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,79 +0,0 @@
import { Activity, Radio, Terminal } from 'lucide-react';
import { useAppStore } from '../../store/appStore';
import { WorkingDeck } from './components/WorkingDeck';
import { pickLocale } from '../../i18n';
import { legacyDeckZhCn } from '../../i18n/legacy-deck.zh-cn';
import { legacyDeckEn } from '../../i18n/legacy-deck.en';
export function DashboardModule() {
const activeBots = useAppStore((state) => state.activeBots);
const locale = useAppStore((state) => state.locale);
const t = pickLocale(locale, { 'zh-cn': legacyDeckZhCn, en: legacyDeckEn });
const runningBots = Object.values(activeBots).filter((b) => b.docker_status === 'RUNNING');
return (
<div className="w-full h-full relative animate-in fade-in zoom-in duration-700">
<div className="absolute inset-0">
<WorkingDeck />
</div>
<div className="absolute top-8 left-8 p-6 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl pointer-events-none shadow-2xl">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-blue-600/20 rounded-xl flex items-center justify-center border border-blue-500/30">
<Radio className="text-blue-400 animate-pulse" size={24} />
</div>
<div>
<h1 className="text-xl font-black tracking-tighter text-white">
{t.operationsDeck}
</h1>
<p className="text-[10px] text-slate-400 uppercase font-bold tracking-[0.2em]">{t.sandbox}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between gap-8 text-[10px] font-bold">
<span className="text-slate-500">{t.activeNodes}</span>
<span className="text-blue-400">
{runningBots.length} / {Object.keys(activeBots).length}
</span>
</div>
<div className="w-full h-1 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-1000"
style={{ width: `${(runningBots.length / (Object.keys(activeBots).length || 1)) * 100}%` }}
/>
</div>
</div>
</div>
<div className="absolute top-8 right-8 space-y-4 pointer-events-none">
{runningBots.slice(0, 3).map((bot) => (
<div key={bot.id} className="p-4 bg-black/40 backdrop-blur-xl border border-white/10 rounded-xl flex items-center gap-4 min-w-[240px] animate-in slide-in-from-right-4">
<div className="p-2 bg-green-500/10 rounded-lg">
<Activity size={16} className="text-green-500" />
</div>
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-bold text-white">{bot.name}</span>
<span className="text-[8px] font-mono text-green-500 uppercase">{bot.current_state || t.idle}</span>
</div>
<p className="text-[9px] text-slate-500 truncate max-w-[150px]">{bot.last_action || t.standby}</p>
</div>
</div>
))}
</div>
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-[60%] p-4 bg-black/40 backdrop-blur-xl border border-white/10 rounded-2xl flex items-center gap-6">
<div className="flex items-center gap-2 text-slate-400 shrink-0">
<Terminal size={14} />
<span className="text-[10px] font-black uppercase tracking-widest">{t.globalEvents}</span>
</div>
<div className="flex-1 overflow-hidden h-5">
<div className="animate-marquee whitespace-nowrap text-xs font-mono text-blue-400/80">
{runningBots.map((b) => `[${b.name}] ${b.last_action || t.listening}`).join(' • ') ||
t.standby}
</div>
</div>
</div>
</div>
);
}

View File

@ -215,10 +215,12 @@ export function BotListPanel({
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
const showActionPending = isOperating && !isEnabling && !isDisabling;
const showActionPending = isStarting || isStopping;
return (
<div
key={bot.id}

View File

@ -1,51 +0,0 @@
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
interface VoxelBotProps {
name: string;
currentState?: string;
lastAction?: string;
position: [number, number, number];
}
export function VoxelBot({ name, currentState, lastAction, position }: VoxelBotProps) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
if (currentState === 'THINKING') {
meshRef.current.position.y = Math.sin(state.clock.elapsedTime * 10) * 0.1;
} else if (currentState === 'TOOL_CALL') {
meshRef.current.rotation.y += 0.1;
} else {
meshRef.current.position.y = 0;
}
});
const stateColor =
currentState === 'THINKING'
? '#3b82f6'
: currentState === 'TOOL_CALL'
? '#f59e0b'
: currentState === 'SUCCESS'
? '#10b981'
: '#4b5563';
return (
<group position={position}>
<mesh ref={meshRef} castShadow receiveShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={stateColor} />
</mesh>
<Html position={[0, 1.2, 0]} center>
<div className="bg-black/80 text-white px-2 py-1 rounded text-xs whitespace-nowrap shadow-lg">
<p className="font-bold">{name}</p>
{lastAction && <p className="text-gray-400 italic text-[10px]">{lastAction}</p>}
</div>
</Html>
</group>
);
}

View File

@ -1,53 +0,0 @@
import { Canvas } from '@react-three/fiber';
import { ContactShadows, OrbitControls } from '@react-three/drei';
import { useAppStore } from '../../../store/appStore';
import { VoxelBot } from './VoxelBot';
import { pickLocale } from '../../../i18n';
import { legacyDeckZhCn } from '../../../i18n/legacy-deck.zh-cn';
import { legacyDeckEn } from '../../../i18n/legacy-deck.en';
export function WorkingDeck() {
const activeBots = useAppStore((state) => state.activeBots);
const locale = useAppStore((state) => state.locale);
const t = pickLocale(locale, { 'zh-cn': legacyDeckZhCn, en: legacyDeckEn });
return (
<div className="w-full h-full bg-slate-900 rounded-xl overflow-hidden shadow-2xl relative">
<Canvas shadows camera={{ position: [5, 5, 5], fov: 40 }}>
<color attach="background" args={['#0f172a']} />
<ambientLight intensity={0.7} />
<directionalLight position={[10, 10, 5]} intensity={1.5} castShadow />
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.5, 0]} receiveShadow>
<planeGeometry args={[20, 20]} />
<meshStandardMaterial color="#1e293b" roughness={0.8} />
</mesh>
<ContactShadows position={[0, -0.49, 0]} scale={20} blur={2} far={4.5} />
{Object.values(activeBots).map((bot, index) => {
const x = (index % 5) * 2 - 4;
const z = Math.floor(index / 5) * 2 - 2;
return (
<VoxelBot
key={bot.id}
name={bot.name}
currentState={bot.current_state}
lastAction={bot.last_action}
position={[x, 0, z]}
/>
);
})}
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2.2} />
</Canvas>
<div className="absolute top-4 left-4 bg-black/40 backdrop-blur-md p-3 rounded-lg border border-white/10 text-white">
<h2 className="text-lg font-bold flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
{t.deckTitle}
</h2>
<p className="text-xs text-gray-400">{t.deckSub}</p>
</div>
</div>
);
}

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BotState } from '../../../types/bot';
import type { CompactPanelTab, RuntimeViewMode } from '../types';
import { parseBotTimestamp } from '../utils';
import { sortBotsByCreatedAtDesc } from '../utils';
interface UseDashboardShellStateOptions {
activeBots: Record<string, BotState>;
@ -38,13 +38,7 @@ export function useDashboardShellState({
const controlCommandPanelRef = useRef<HTMLDivElement | null>(null);
const bots = useMemo(
() =>
Object.values(activeBots).sort((a, b) => {
const aCreated = parseBotTimestamp(a.created_at);
const bCreated = parseBotTimestamp(b.created_at);
if (aCreated !== bCreated) return bCreated - aCreated;
return String(a.id || '').localeCompare(String(b.id || ''));
}),
() => sortBotsByCreatedAtDesc(Object.values(activeBots)),
[activeBots],
);

View File

@ -150,7 +150,7 @@ export function normalizeRuntimeState(s?: string) {
return raw;
}
export function parseBotTimestamp(raw?: string | number) {
export function parseBotTimestamp(raw?: string | number | null) {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const text = String(raw || '').trim();
if (!text) return 0;
@ -158,6 +158,17 @@ export function parseBotTimestamp(raw?: string | number) {
return Number.isFinite(ms) ? ms : 0;
}
export function sortBotsByCreatedAtDesc<T extends { id?: string | number | null; created_at?: string | number | null }>(
bots: readonly T[],
): T[] {
return [...bots].sort((left, right) => {
const leftCreated = parseBotTimestamp(left.created_at);
const rightCreated = parseBotTimestamp(right.created_at);
if (leftCreated !== rightCreated) return rightCreated - leftCreated;
return String(left.id || '').localeCompare(String(right.id || ''));
});
}
export function normalizeWorkspaceExtension(raw: unknown): string {
const value = String(raw ?? '').trim().toLowerCase();
if (!value) return '';

View File

@ -1,324 +0,0 @@
import { Power, PowerOff, Terminal, ShieldCheck, Plus, Bot, Cpu, Layers, RefreshCw } from 'lucide-react';
import { ProtectedSearchInput } from '../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { CreateBotModal } from './components/CreateBotModal';
import { KernelManagerModal } from './components/KernelManagerModal';
import { WorkspaceEntriesList } from '../dashboard/components/WorkspaceEntriesList';
import { WorkspaceHoverCard } from '../dashboard/components/WorkspaceHoverCard';
import { WorkspacePreviewModal } from '../dashboard/components/WorkspacePreviewModal';
import { formatBytes, formatWorkspaceTime } from '../dashboard/utils';
import { useManagementModule } from './hooks/useManagementModule';
import '../dashboard/components/BotListPanel.css';
import '../dashboard/components/RuntimePanel.css';
import '../dashboard/components/DashboardShared.css';
import '../../components/ui/SharedUi.css';
export function ManagementModule() {
const management = useManagementModule();
const {
closeWorkspacePreview,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
filteredWorkspaceEntries,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
hideWorkspaceHoverCard,
loadWorkspaceTree,
openWorkspaceFilePreview,
saveWorkspacePreviewMarkdown,
setWorkspaceAutoRefresh,
setWorkspacePreviewDraft,
setWorkspacePreviewFullscreen,
setWorkspacePreviewMode,
setWorkspaceQuery,
showWorkspaceHoverCard,
workspaceAutoRefresh,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
workspaceError,
workspaceFileLoading,
workspaceFiles,
workspaceHoverCard,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspacePreview,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewEditorEnabled,
workspacePreviewFullscreen,
workspacePreviewMarkdownComponents,
workspacePreviewSaving,
workspaceQuery,
workspaceSearchLoading,
} = management.workspace;
return (
<div className="flex h-full gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="w-1/3 flex flex-col gap-4">
<div className="flex justify-between items-center px-2">
<h2 className="text-sm font-black text-slate-500 uppercase tracking-widest">{management.t.botInstances}</h2>
<div className="flex gap-2">
<button onClick={() => management.setIsKernelModalOpen(true)} className="p-2 bg-slate-800 hover:bg-slate-700 rounded-lg transition-all text-blue-400 border border-blue-500/20 shadow-lg shadow-blue-500/10">
<ShieldCheck size={16} />
</button>
<button onClick={() => management.setIsModalOpen(true)} className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 px-3 py-2 rounded-lg text-white font-bold text-xs transition-all active:scale-95 shadow-lg shadow-blue-600/30">
<Plus size={14} /> {management.t.newBot}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-thin">
{Object.values(management.activeBots).map((bot) => (
<div
key={bot.id}
onClick={() => management.setSelectedBotId(bot.id)}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
management.selectedBotId === bot.id ? 'bg-blue-600/10 border-blue-500/50' : 'bg-slate-900/50 border-white/5 hover:border-white/10'
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${bot.docker_status === 'RUNNING' ? 'bg-green-500/10 text-green-500' : 'bg-slate-800 text-slate-500'}`}>
<Bot size={18} />
</div>
<div>
<h3 className="text-sm font-bold">{bot.name}</h3>
<p className="text-[10px] font-mono text-slate-500">{bot.id}</p>
</div>
</div>
<button
disabled={management.operatingBotId === bot.id}
onClick={(e) => {
e.stopPropagation();
void management.toggleBot(bot.id, bot.docker_status);
}}
className={`p-2 rounded-lg transition-all ${management.operatingBotId === bot.id ? 'opacity-50 cursor-not-allowed' : ''} ${bot.docker_status === 'RUNNING' ? 'bg-red-500/10 text-red-400' : 'bg-green-500/10 text-green-400'}`}
>
{management.operatingBotId === bot.id ? (
<RefreshCw size={14} className="animate-spin" />
) : bot.docker_status === 'RUNNING' ? (
<PowerOff size={14} />
) : (
<Power size={14} />
)}
</button>
</div>
</div>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-6">
{management.selectedBot ? (
<>
<div className="bg-slate-900/50 border border-white/5 rounded-2xl p-6">
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-2xl font-black">{management.selectedBot.name}</h2>
<p className="text-xs text-slate-500 mt-1">{management.t.botOverview}</p>
</div>
<div className={`px-3 py-1 rounded-full text-[10px] font-black uppercase ${management.selectedBot.docker_status === 'RUNNING' ? 'bg-green-500/10 text-green-500 border border-green-500/20' : 'bg-slate-800 text-slate-500 border border-white/5'}`}>
{management.selectedBot.docker_status}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
<div className="flex items-center gap-2 text-slate-500 mb-2">
<Layers size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{management.t.kernel}</span>
</div>
<p className="text-sm font-mono text-blue-400 truncate">{management.selectedBot.image_tag || 'nanobot-base:v0.1.4'}</p>
</div>
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
<div className="flex items-center gap-2 text-slate-500 mb-2">
<Cpu size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{management.t.model}</span>
</div>
<p className="text-sm font-bold text-white">{management.selectedBot.llm_model || '-'}</p>
</div>
<div className="bg-black/20 p-4 rounded-xl border border-white/5">
<div className="flex items-center gap-2 text-slate-500 mb-2">
<Terminal size={14} /> <span className="text-[10px] font-bold uppercase tracking-widest">{management.t.status}</span>
</div>
<p className="text-sm font-bold text-white">{management.selectedBot.current_state || management.t.idle}</p>
</div>
</div>
</div>
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.85fr)]">
<div className="bg-slate-900/50 border border-white/5 rounded-2xl p-6 flex min-h-0 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{management.t.workspace}</div>
<p className="mt-1 text-xs text-slate-500">{management.dashboardT.workspaceHint}</p>
</div>
</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}
onClick={() => {
if (!management.selectedBot) return;
void loadWorkspaceTree(management.selectedBot.id, workspaceCurrentPath);
}}
tooltip={management.isZh ? '刷新工作区' : 'Refresh workspace'}
aria-label={management.isZh ? '刷新工作区' : 'Refresh workspace'}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={management.dashboardT.autoRefresh}>
<span className="workspace-auto-switch-label">{management.dashboardT.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={() => setWorkspaceAutoRefresh((value) => !value)}
aria-label={management.dashboardT.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div>
<div className="workspace-search-toolbar">
<ProtectedSearchInput
value={workspaceQuery}
onChange={setWorkspaceQuery}
onClear={() => setWorkspaceQuery('')}
onSearchAction={() => setWorkspaceQuery(workspaceQuery.trim())}
debounceMs={200}
placeholder={management.dashboardT.workspaceSearchPlaceholder}
ariaLabel={management.dashboardT.workspaceSearchPlaceholder}
clearTitle={management.dashboardT.clearSearch}
searchTitle={management.dashboardT.searchAction}
name={management.workspaceSearchInputName}
id={management.workspaceSearchInputName}
/>
</div>
<div className="workspace-panel mt-3 flex-1">
<div className="workspace-list">
{workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{management.dashboardT.loadingDir}</div>
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
<div className="ops-empty-inline">
{workspaceQuery.trim() ? management.dashboardT.workspaceSearchNoResult : management.dashboardT.emptyDir}
</div>
) : (
<WorkspaceEntriesList
nodes={filteredWorkspaceEntries}
workspaceParentPath={workspaceParentPath}
selectedBotId={management.selectedBot.id}
workspaceFileLoading={workspaceFileLoading}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
labels={{
download: management.dashboardT.download,
fileNotPreviewable: management.dashboardT.fileNotPreviewable,
folder: management.dashboardT.folder,
goUp: management.dashboardT.goUp,
goUpTitle: management.dashboardT.goUpTitle,
openFolderTitle: management.dashboardT.openFolderTitle,
previewTitle: management.dashboardT.previewTitle,
}}
onLoadWorkspaceTree={loadWorkspaceTree}
onOpenWorkspaceFilePreview={openWorkspaceFilePreview}
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
/>
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading ? management.dashboardT.openingPreview : management.dashboardT.workspaceHint}
</div>
</div>
{!workspaceFiles.length ? (
<div className="ops-empty-inline mt-3">{management.dashboardT.noPreviewFile}</div>
) : null}
</div>
<div className="bg-black rounded-2xl border border-white/5 flex min-h-0 flex-col overflow-hidden shadow-2xl">
<div className="px-6 py-3 bg-white/[0.02] border-b border-white/5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Terminal size={14} className="text-blue-500" />
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{management.t.dockerLogs}</span>
</div>
<span className="text-[10px] text-slate-500">{management.t.dockerLogsHint}</span>
</div>
<div className="flex-1 p-6 font-mono text-[11px] overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-white/10">
{management.recentLogs.length > 0 ? management.recentLogs.map((log, i) => {
const totalLogs = management.selectedBot?.logs.length ?? management.recentLogs.length;
const logNumber = (totalLogs - management.recentLogs.length + i + 1).toString().padStart(3, '0');
return (
<div key={`${logNumber}-${i}`} className="flex gap-4 border-b border-white/[0.01] pb-1">
<span className="text-slate-700 shrink-0">{logNumber}</span>
<span className={log.includes('Executing') ? 'text-amber-400' : 'text-slate-400'}>{log}</span>
</div>
);
}) : (
<div className="text-slate-500">{management.t.dockerLogsEmpty}</div>
)}
</div>
</div>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center border border-dashed border-white/5 rounded-2xl text-slate-600">
<Bot size={48} className="mb-4 opacity-20" />
<p className="text-sm italic tracking-wide">{management.t.selectHint}</p>
</div>
)}
</div>
<CreateBotModal isOpen={management.isModalOpen} onClose={() => management.setIsModalOpen(false)} onSuccess={management.fetchBots} />
<KernelManagerModal isOpen={management.isKernelModalOpen} onClose={() => management.setIsKernelModalOpen(false)} />
<WorkspacePreviewModal
isZh={management.isZh}
labels={{
cancel: management.dashboardT.cancel,
close: management.dashboardT.close,
copyAddress: management.dashboardT.copyAddress,
download: management.dashboardT.download,
editFile: management.dashboardT.editFile,
filePreview: management.dashboardT.filePreview,
fileTruncated: management.dashboardT.fileTruncated,
save: management.dashboardT.save,
}}
preview={workspacePreview}
previewFullscreen={workspacePreviewFullscreen}
previewEditorEnabled={workspacePreviewEditorEnabled}
previewCanEdit={workspacePreviewCanEdit}
previewDraft={workspacePreviewDraft}
previewSaving={workspacePreviewSaving}
markdownComponents={workspacePreviewMarkdownComponents}
onClose={closeWorkspacePreview}
onToggleFullscreen={() => setWorkspacePreviewFullscreen((value) => !value)}
onCopyPreviewPath={copyWorkspacePreviewPath}
onCopyPreviewUrl={copyWorkspacePreviewUrl}
onPreviewDraftChange={setWorkspacePreviewDraft}
onSavePreviewMarkdown={saveWorkspacePreviewMarkdown}
onEnterEditMode={() => setWorkspacePreviewMode('edit')}
onExitEditMode={() => {
setWorkspacePreviewMode('preview');
setWorkspacePreviewDraft(workspacePreview?.content || '');
}}
getWorkspaceDownloadHref={getWorkspaceDownloadHref}
getWorkspaceRawHref={getWorkspaceRawHref}
/>
<WorkspaceHoverCard
state={workspaceHoverCard}
isZh={management.isZh}
formatWorkspaceTime={formatWorkspaceTime}
formatBytes={formatBytes}
/>
</div>
);
}

View File

@ -1,199 +0,0 @@
import { useState, useEffect } from 'react';
import { X, Save, Bot, Cpu, Key, FileText, Layers } from 'lucide-react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import { useAppStore } from '../../../store/appStore';
import { pickLocale } from '../../../i18n';
import { managementZhCn } from '../../../i18n/management.zh-cn';
import { managementEn } from '../../../i18n/management.en';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import { PasswordInput } from '../../../components/PasswordInput';
interface CreateBotModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface NanobotImage {
tag: string;
status: string;
source_dir?: string;
}
export function CreateBotModal({ isOpen, onClose, onSuccess }: CreateBotModalProps) {
const locale = useAppStore((s) => s.locale);
const { notify } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).create;
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const [formData, setFormData] = useState({
id: '',
name: '',
llm_provider: 'openai',
llm_model: 'gpt-4o',
api_key: '',
system_prompt: t.systemPrompt,
image_tag: 'nanobot-base:v0.1.4',
});
const [availableImages, setAvailableImages] = useState<NanobotImage[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!isOpen) {
return;
}
axios
.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`)
.then((res) => setAvailableImages(res.data.filter((img) => img.status === 'READY' || img.status === 'UNKNOWN')))
.catch((err) => console.error('Failed to fetch images', err));
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots`, formData);
onSuccess();
onClose();
} catch {
notify(t.createFail, { tone: 'error' });
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center p-6 border-b border-slate-800 bg-slate-800/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg text-white">
<Bot className="text-blue-400" size={24} />
</div>
<h2 className="text-xl font-bold text-white">{t.title}</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-full transition-colors text-white">
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto max-h-[80vh] scrollbar-thin scrollbar-thumb-white/10 pr-2">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">{t.idLabel}</label>
<input
required
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
placeholder={t.idPlaceholder}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">{t.nameLabel}</label>
<input
required
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
placeholder={t.namePlaceholder}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2">
<Layers size={14} className="text-blue-500" /> {t.imageLabel}
</label>
<LucentSelect
onChange={(e) => setFormData({ ...formData, image_tag: e.target.value })}
value={formData.image_tag}
>
{availableImages.map((img) => (
<option key={img.tag} value={img.tag}>
{img.tag} ({img.source_dir ? `${t.source}: ${img.source_dir}` : t.pypi})
</option>
))}
{availableImages.length === 0 && <option disabled>{t.noImage}</option>}
</LucentSelect>
<p className="text-[10px] text-slate-600 italic">{t.imageHint}</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
<Cpu size={14} /> {t.providerLabel}
</label>
<LucentSelect
onChange={(e) => setFormData({ ...formData, llm_provider: e.target.value })}
>
<option value="openai">OpenAI</option>
<option value="vllm">vLLM (OpenAI-compatible)</option>
<option value="deepseek">DeepSeek</option>
<option value="kimi">Kimi (Moonshot)</option>
<option value="minimax">MiniMax</option>
<option value="ollama">Ollama (Local)</option>
</LucentSelect>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 text-white">{t.modelLabel}</label>
<input
required
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
placeholder={t.modelPlaceholder}
defaultValue="gpt-4o"
onChange={(e) => setFormData({ ...formData, llm_model: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
<Key size={14} /> API Key
</label>
<PasswordInput
required
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm focus:border-blue-500 outline-none transition-all text-white"
placeholder="sk-..."
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
toggleLabels={passwordToggleLabels}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 flex items-center gap-2 text-white">
<FileText size={14} /> {t.soulLabel}
</label>
<textarea
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm h-32 focus:border-blue-500 outline-none transition-all resize-none text-white"
defaultValue={formData.system_prompt}
onChange={(e) => setFormData({ ...formData, system_prompt: e.target.value })}
/>
</div>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="flex-1 bg-slate-800 hover:bg-slate-700 py-3 rounded-xl font-bold transition-all text-white"
>
{t.cancel}
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 py-3 rounded-xl font-bold flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-900/40 text-white"
>
<Save size={18} /> {isSubmitting ? t.creating : t.submit}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,113 +0,0 @@
import { useState, useEffect } from 'react';
import { X, Cpu, CheckCircle, AlertCircle, Loader2, HardDrive, Trash2 } from 'lucide-react';
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import { useAppStore } from '../../../store/appStore';
import { pickLocale } from '../../../i18n';
import { managementZhCn } from '../../../i18n/management.zh-cn';
import { managementEn } from '../../../i18n/management.en';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
interface KernelManagerModalProps {
isOpen: boolean;
onClose: () => void;
}
interface NanobotImage {
tag: string;
status: string;
source_dir?: string;
}
export function KernelManagerModal({ isOpen, onClose }: KernelManagerModalProps) {
const locale = useAppStore((s) => s.locale);
const { notify, confirm } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn }).kernelModal;
const [images, setImages] = useState<NanobotImage[]>([]);
const fetchData = async () => {
try {
const imgRes = await axios.get<NanobotImage[]>(`${APP_ENDPOINTS.apiBase}/images`);
setImages(imgRes.data);
} catch (err) {
console.error(t.fetchFailed, err);
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen]);
const handleRemoveImage = async (tag: string) => {
const ok = await confirm({
title: t.removeRecord,
message: t.removeConfirm(tag),
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/images/${encodeURIComponent(tag)}`);
fetchData();
} catch (error: any) {
notify(error.response?.data?.detail || t.removeFail, { tone: 'error' });
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-3xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center p-6 border-b border-slate-800 bg-slate-800/50">
<div className="flex items-center gap-3">
<Cpu className="text-blue-400" size={24} />
<h2 className="text-xl font-bold text-white">{t.title}</h2>
</div>
<LucentIconButton onClick={onClose} className="p-2 hover:bg-slate-700 rounded-full transition-colors text-white" tooltip={locale === 'zh' ? '关闭' : 'Close'} aria-label={locale === 'zh' ? '关闭' : 'Close'}>
<X size={20} />
</LucentIconButton>
</div>
<div className="p-6">
<div className="space-y-6">
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">{t.readySection}</h3>
<div className="space-y-2 h-[300px] overflow-y-auto pr-2">
{images.map((img) => (
<div key={img.tag} className="p-3 rounded-lg bg-white/[0.03] border border-white/5 flex justify-between items-center">
<div>
<p className="text-xs font-bold text-white">{img.tag}</p>
<p className="text-[10px] text-slate-500">{t.source}: {img.source_dir || t.pypi}</p>
</div>
<div className="flex items-center gap-2">
{img.status === 'READY' && <CheckCircle size={14} className="text-green-500" />}
{img.status === 'BUILDING' && <Loader2 size={14} className="text-blue-500 animate-spin" />}
{img.status === 'ERROR' && <AlertCircle size={14} className="text-red-500" />}
<span className={`text-[9px] font-bold ${img.status === 'READY' ? 'text-green-500' : 'text-slate-400'}`}>
{img.status}
</span>
<LucentIconButton
onClick={() => handleRemoveImage(img.tag)}
className="ml-2 p-1.5 hover:bg-red-500/20 text-slate-500 hover:text-red-500 rounded transition-colors"
tooltip={t.removeRecord}
aria-label={t.removeRecord}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
))}
</div>
</div>
</div>
<div className="p-4 bg-slate-800/30 border-t border-slate-800 flex items-center gap-2 text-[10px] text-slate-500">
<HardDrive size={12} />
<span>{t.footerHint}</span>
</div>
</div>
</div>
);
}

View File

@ -1,127 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { APP_ENDPOINTS } from '../../../config/env';
import { pickLocale } from '../../../i18n';
import { dashboardEn } from '../../../i18n/dashboard.en';
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
import { managementEn } from '../../../i18n/management.en';
import { managementZhCn } from '../../../i18n/management.zh-cn';
import { useAppStore } from '../../../store/appStore';
import { useDashboardSystemDefaults } from '../../dashboard/hooks/useDashboardSystemDefaults';
import { useDashboardWorkspace } from '../../dashboard/hooks/useDashboardWorkspace';
export function useManagementModule() {
const { activeBots, setBots, mergeBot, updateBotStatus, locale } = useAppStore();
const { notify } = useLucentPrompt();
const t = pickLocale(locale, { 'zh-cn': managementZhCn, en: managementEn });
const dashboardT = pickLocale(locale, { 'zh-cn': dashboardZhCn, en: dashboardEn });
const isZh = locale === 'zh';
const [selectedBotId, setSelectedBotId] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isKernelModalOpen, setIsKernelModalOpen] = useState(false);
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
const [, setBotListPageSize] = useState(10);
const workspaceSearchInputName = useMemo(
() => `management-workspace-search-${Math.random().toString(36).slice(2, 10)}`,
[],
);
const {
refreshAttachmentPolicy,
workspaceDownloadExtensions,
} = useDashboardSystemDefaults({
setBotListPageSize,
});
const selectedBot = selectedBotId ? activeBots[selectedBotId] : null;
const recentLogs = useMemo(() => (selectedBot?.logs || []).slice(-10), [selectedBot?.logs]);
const workspace = useDashboardWorkspace({
selectedBotId: selectedBotId || '',
selectedBotDockerStatus: selectedBot?.docker_status || '',
workspaceDownloadExtensions,
refreshAttachmentPolicy,
notify,
t: dashboardT,
isZh,
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
});
const { loadWorkspaceTree, resetWorkspaceState } = workspace;
const fetchBots = async () => {
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`);
setBots(res.data);
} catch (error) {
console.error(error);
}
};
const toggleBot = async (botId: string, status: string) => {
setOperatingBotId(botId);
try {
if (status === 'RUNNING') {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/stop`);
updateBotStatus(botId, 'STOPPED');
} else {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/start`);
updateBotStatus(botId, 'RUNNING');
}
await fetchBots();
} catch {
notify(t.opFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
}
};
useEffect(() => {
if (!selectedBotId) return;
let alive = true;
const loadDetail = async () => {
try {
const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}`);
if (alive) {
mergeBot(res.data);
}
} catch (error) {
console.error(error);
}
};
void loadDetail();
return () => {
alive = false;
};
}, [selectedBotId, mergeBot]);
useEffect(() => {
if (!selectedBotId) {
resetWorkspaceState();
return;
}
resetWorkspaceState();
void loadWorkspaceTree(selectedBotId, '');
}, [loadWorkspaceTree, resetWorkspaceState, selectedBotId]);
return {
activeBots,
dashboardT,
fetchBots,
isKernelModalOpen,
isModalOpen,
isZh,
operatingBotId,
recentLogs,
selectedBot,
selectedBotId,
setIsKernelModalOpen,
setIsModalOpen,
setSelectedBotId,
t,
toggleBot,
workspace,
workspaceSearchInputName,
};
}

View File

@ -3,6 +3,7 @@ import axios from 'axios';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { APP_ENDPOINTS } from '../../../config/env';
import { sortBotsByCreatedAtDesc } from '../../dashboard/utils';
import { useAppStore } from '../../../store/appStore';
import type { BotState } from '../../../types/bot';
import {
@ -62,15 +63,8 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
const compactSheetTimerRef = useRef<number | null>(null);
const botList = useMemo(() => {
const overviewBots = overview?.bots || [];
if (overviewBots.length > 0) {
return overviewBots.map((bot) => ({
...(activeBots[bot.id] || { logs: [], messages: [], events: [] }),
...bot,
})) as BotState[];
}
return Object.values(activeBots);
}, [activeBots, overview?.bots]);
return sortBotsByCreatedAtDesc(Object.values(activeBots)) as BotState[];
}, [activeBots]);
const filteredBots = useMemo(() => {
const keyword = search.trim().toLowerCase();
@ -173,8 +167,7 @@ export function usePlatformDashboard({ compactMode }: UsePlatformDashboardOption
useEffect(() => {
void loadOverview();
void loadBots();
}, [loadBots, loadOverview]);
}, [loadOverview]);
useEffect(() => {
if (!pageSizeReady) return;

View File

@ -131,24 +131,6 @@ export interface PlatformOverviewResponse {
source_dir?: string;
created_at: string;
}>;
bots: Array<{
id: string;
name: string;
enabled: boolean;
docker_status: string;
image_tag?: string;
llm_provider?: string;
llm_model?: string;
current_state?: string;
last_action?: string;
resources: {
cpu_cores: number;
memory_mb: number;
storage_gb: number;
};
workspace_usage_bytes: number;
workspace_limit_bytes?: number | null;
}>;
settings: PlatformSettings;
usage: {
summary: {

View File

@ -66,6 +66,8 @@ export const useAppStore = create<AppStore>((set) => ({
...prev,
...bot,
enabled: preferDefined(bot.enabled, prev?.enabled),
created_at: preferDefined(bot.created_at, prev?.created_at),
updated_at: preferDefined(bot.updated_at, prev?.updated_at),
image_tag: preferDefined(bot.image_tag, prev?.image_tag),
llm_provider: preferDefined(bot.llm_provider, prev?.llm_provider),
llm_model: preferDefined(bot.llm_model, prev?.llm_model),
@ -105,6 +107,8 @@ export const useAppStore = create<AppStore>((set) => ({
...prev,
...bot,
enabled: preferDefined(bot.enabled, prev?.enabled),
created_at: preferDefined(bot.created_at, prev?.created_at),
updated_at: preferDefined(bot.updated_at, prev?.updated_at),
logs: prev?.logs ?? bot.logs ?? [],
messages: prev?.messages ?? bot.messages ?? [],
events: prev?.events ?? bot.events ?? [],