feat: Clean up dashboard UI, shift workflow WS to SSE, and add file upload support (#37)

- Removed Monitoring view and associated `/ws/state` cluster websocket route.
- Modified workflow tracing from WebSocket (`/api/v1/workflow/ws/{trace_id}`) to Server-Sent Events (`/api/v1/workflow/sse/{trace_id}`) for unidirectional pushes, introducing a new `/api/v1/workflow/reply/{trace_id}` POST route to handle incoming client replies.
- Cleaned up dummy data and unneeded links in the chat layout (LeftPanel, ChatPanel).
- Implemented file upload functionality: added a `/api/v1/adapter/client/upload` endpoint to the backend which saves files to a local `uploads` directory, and added an integrated file input triggered via the `+` button in the frontend chat interface to facilitate uploading with an automated chat message confirmation.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
This commit is contained in:
朝夕 2026-04-27 09:45:45 +08:00 committed by GitHub
parent dc1c440703
commit 71963ac4a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 114 additions and 296 deletions

View File

@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { Sidebar } from './components/Layout/Sidebar';
import { MonitoringLayout } from './components/Monitoring/MonitoringLayout';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout';
import { ResourceLayout } from './components/Resource/ResourceLayout';
@ -12,7 +11,7 @@ import { AuthPage } from './components/Auth/AuthPage';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [activeTab, setActiveTab] = useState('chats'); // For LeftPanel
const [currentView, setCurrentView] = useState('dashboard'); // 'dashboard', 'settings', 'monitoring', 'agent', 'resource'
const [currentView, setCurrentView] = useState('dashboard'); // 'dashboard', 'settings', 'agent', 'resource'
const [settingsTab, setSettingsTab] = useState('users'); // For SettingsLayout
const [agentTab, setAgentTab] = useState('worker'); // For AgentLayout
const [resourceTab, setResourceTab] = useState('skill'); // For ResourceLayout
@ -38,9 +37,7 @@ function App() {
<Sidebar currentView={currentView} setCurrentView={setCurrentView} />
{/* Main Content Area depending on view */}
{currentView === 'monitoring' ? (
<MonitoringLayout />
) : currentView === 'agent' ? (
{currentView === 'agent' ? (
<AgentLayout agentTab={agentTab} setAgentTab={setAgentTab} />
) : currentView === 'resource' ? (
<ResourceLayout resourceTab={resourceTab} setResourceTab={setResourceTab} />

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
import apiClient from '../../api/client';
@ -21,6 +21,45 @@ export function ChatPanel() {
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
setLoading(true);
try {
const response = await apiClient.post('/api/v1/adapter/client/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const aiMessage: ChatMessage = {
id: Date.now().toString(),
sender: 'ai',
text: `已上传文件: ${response.data.filename}`,
timestamp: new Date()
};
setMessages(prev => [...prev, aiMessage]);
} catch (error) {
console.error("Error uploading file", error);
const errorMessage: ChatMessage = {
id: Date.now().toString(),
sender: 'ai',
text: "文件上传失败。",
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleSendMessage = async () => {
if (!input.trim()) return;
@ -124,7 +163,14 @@ export function ChatPanel() {
{/* Chat Input */}
<div className="p-4 bg-white border-t border-slate-200">
<div className="relative flex items-center">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="absolute left-2 p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors z-10 cursor-pointer"
title="Add attachment"
>
@ -146,10 +192,6 @@ export function ChatPanel() {
<ChevronRight size={18} />
</button>
</div>
<div className="flex mt-2 space-x-3 px-2">
<span className="text-[10px] text-slate-400 cursor-pointer hover:text-blue-500">Run diagnostics</span>
<span className="text-[10px] text-slate-400 cursor-pointer hover:text-blue-500">View recent errors</span>
</div>
</div>
</div>
);

View File

@ -154,14 +154,6 @@ export function LeftPanel({ activeTab, setActiveTab, selectedWorkflow, setSelect
)}
{activeTab === 'chats' && (
<div className="space-y-2">
<div className="p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-slate-50 cursor-pointer transition-all">
<div className="font-medium text-sm text-slate-700 mb-1">System Architecture</div>
<p className="text-xs text-slate-400 line-clamp-1">Can you explain the MoE model...</p>
</div>
<div className="p-3 rounded-lg border border-slate-100 hover:border-blue-200 hover:bg-slate-50 cursor-pointer transition-all">
<div className="font-medium text-sm text-slate-700 mb-1">Log Analysis Helper</div>
<p className="text-xs text-slate-400 line-clamp-1">Show me the errors from yesterday.</p>
</div>
</div>
)}
</div>

View File

@ -16,56 +16,43 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
return;
}
let ws: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout>;
let retryCount = 0;
const maxRetryCount = 10;
const baseDelay = 1000;
let eventSource: EventSource | null = null;
const connect = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const protocol = window.location.protocol;
const host = window.location.host;
const wsBase = import.meta.env.VITE_API_BASE_URL
? import.meta.env.VITE_API_BASE_URL.replace(/^http/, 'ws')
: `${protocol}//${host}`;
const apiBase = import.meta.env.VITE_API_BASE_URL || `${protocol}//${host}`;
// Using the workflow router WS endpoint
ws = new WebSocket(`${wsBase}/api/v1/workflow/ws/${selectedWorkflow}`);
// Using the workflow router SSE endpoint
eventSource = new EventSource(`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`);
ws.onopen = () => {
eventSource.onopen = () => {
setIsConnected(true);
retryCount = 0; // reset on successful connection
setMessages([]); // clear previous traces
};
ws.onmessage = (event) => {
eventSource.onmessage = (event) => {
try {
setMessages(prev => [...prev, event.data]);
} catch (e) {
console.error("Error receiving workflow websocket message", e);
console.error("Error receiving workflow SSE message", e);
}
};
ws.onclose = () => {
eventSource.onerror = (error) => {
console.error("EventSource failed.", error);
setIsConnected(false);
if (retryCount < maxRetryCount) {
const delay = baseDelay * Math.pow(2, retryCount);
retryCount++;
console.log(`WebSocket closed. Reconnecting in ${delay}ms... (Attempt ${retryCount})`);
reconnectTimeout = setTimeout(connect, delay);
} else {
console.error("Max WebSocket reconnect attempts reached.");
}
// EventSource automatically attempts to reconnect, so we can just let it be,
// or we could close it if we wanted to handle retries manually.
};
};
connect();
return () => {
clearTimeout(reconnectTimeout);
if (ws) {
ws.close();
if (eventSource) {
eventSource.close();
}
};
}, [selectedWorkflow]);

View File

@ -1,5 +1,5 @@
import { Activity, MessageSquare, MonitorPlay, Settings, Bot, Box } from 'lucide-react';
import { Activity, MessageSquare, Settings, Bot, Box } from 'lucide-react';
interface SidebarProps {
currentView: string;
@ -24,13 +24,6 @@ export function Sidebar({ currentView, setCurrentView }: SidebarProps) {
>
<MessageSquare size={18} />
</button>
<button
onClick={() => setCurrentView('monitoring')}
className={`p-1.5 rounded-lg transition-colors ${currentView === 'monitoring' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}
title="Monitoring"
>
<MonitorPlay size={18} />
</button>
<button
onClick={() => setCurrentView('agent')}
className={`p-1.5 rounded-lg transition-colors ${currentView === 'agent' ? 'text-blue-600 bg-blue-50' : 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'}`}

View File

@ -1,154 +0,0 @@
import { Server, Cpu, HardDrive, Box } from 'lucide-react';
import type { ClusterNode } from '../../types';
import { useClusterState } from '../../hooks/useClusterState';
export function MonitoringDashboard() {
const { nodes, isConnected } = useClusterState();
const totalNodes = nodes.length;
let totalCpu = 0;
let totalMemory = 0;
let totalGpu = 0;
nodes.forEach((node: ClusterNode) => {
totalCpu += node.resources?.CPU || 0;
totalMemory += node.resources?.memory || 0;
totalGpu += node.resources?.GPU || 0;
});
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
<div className="p-6 border-b border-slate-200 bg-white shadow-sm z-10 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">Ray Cluster Monitoring</h2>
<p className="text-sm text-slate-500 mt-1">Real-time resource utilization across all nodes.</p>
</div>
<div className="flex items-center space-x-2">
<span className={`flex h-2.5 w-2.5 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
<span className="text-sm font-medium text-slate-600">{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Cluster Global Metrics */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center mr-4">
<Server size={24} className="text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL NODES</p>
<p className="text-2xl font-bold text-slate-800">{totalNodes}</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-indigo-50 flex items-center justify-center mr-4">
<Cpu size={24} className="text-indigo-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL CPU CORES</p>
<p className="text-2xl font-bold text-slate-800">{totalCpu}</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-green-50 flex items-center justify-center mr-4">
<HardDrive size={24} className="text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL RAM</p>
<p className="text-2xl font-bold text-slate-800">
{totalMemory > 0 ? `${(totalMemory / (1024 * 1024 * 1024)).toFixed(2)} GB` : '0 GB'}
</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center">
<div className="w-12 h-12 rounded-lg bg-purple-50 flex items-center justify-center mr-4">
<Box size={24} className="text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500 font-medium">TOTAL GPUS</p>
<p className="text-2xl font-bold text-slate-800">{totalGpu}</p>
</div>
</div>
</div>
{/* Node List */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Node ID / Name</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium">CPU (Used / Total)</th>
<th className="px-6 py-4 font-medium">RAM (Used / Total)</th>
<th className="px-6 py-4 font-medium">GPU (Used / Total)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{nodes.map((node: ClusterNode, i: number) => {
const totalCpu = node.resources?.CPU || 0;
const remainingCpu = node.remaining?.CPU || 0;
const usedCpu = totalCpu - remainingCpu;
const cpuPercent = totalCpu > 0 ? (usedCpu / totalCpu) * 100 : 0;
const totalRam = node.resources?.memory || 0;
const remainingRam = node.remaining?.memory || 0;
const usedRam = totalRam - remainingRam;
const ramPercent = totalRam > 0 ? (usedRam / totalRam) * 100 : 0;
const totalGpu = node.resources?.GPU || 0;
const remainingGpu = node.remaining?.GPU || 0;
const usedGpu = totalGpu - remainingGpu;
const gpuPercent = totalGpu > 0 ? (usedGpu / totalGpu) * 100 : 0;
return (
<tr key={i} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-medium text-slate-800 flex flex-col">
<span>{node.node_name || 'Unknown'}</span>
<span className="text-xs text-slate-400 font-mono">{node.node_id}</span>
</td>
<td className="px-6 py-4">
<span className={`flex items-center text-xs font-medium ${node.alive ? 'text-green-600' : 'text-red-600'}`}>
<span className={`w-2 h-2 rounded-full mr-2 ${node.alive ? 'bg-green-500' : 'bg-red-500'}`}></span>
{node.alive ? 'Alive' : 'Dead'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<span className="w-16 text-right mr-2 text-xs">{usedCpu.toFixed(1)} / {totalCpu}</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${cpuPercent > 80 ? 'bg-red-500' : 'bg-indigo-500'}`} style={{ width: `${cpuPercent}%` }}></div></div>
</div>
</td>
<td className="px-6 py-4 text-slate-600 text-xs">
<div className="flex items-center">
<span className="w-24 text-right mr-2 text-xs">{(usedRam / (1024**3)).toFixed(1)}G / {(totalRam / (1024**3)).toFixed(1)}G</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${ramPercent > 80 ? 'bg-red-500' : 'bg-green-500'}`} style={{ width: `${ramPercent}%` }}></div></div>
</div>
</td>
<td className="px-6 py-4">
{totalGpu > 0 ? (
<div className="flex items-center">
<span className="w-16 text-right mr-2 text-xs">{usedGpu} / {totalGpu}</span>
<div className="w-16 bg-slate-100 rounded-full h-1.5"><div className={`h-1.5 rounded-full ${gpuPercent > 80 ? 'bg-red-500' : 'bg-purple-500'}`} style={{ width: `${gpuPercent}%` }}></div></div>
</div>
) : (
<span className="text-slate-400 italic text-xs">No GPU</span>
)}
</td>
</tr>
);
})}
{nodes.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-slate-500 text-sm">
No node data available. {isConnected ? 'Waiting for cluster state...' : 'Check connection.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -1,33 +0,0 @@
import { useState } from 'react';
import { Server } from 'lucide-react';
import { MonitoringDashboard } from './MonitoringDashboard';
export function MonitoringLayout() {
const [activeTab, setActiveTab] = useState('cluster');
return (
<div className="flex-1 flex bg-slate-50 overflow-hidden">
{/* Monitoring Inner Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-800">Monitoring</h2>
</div>
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
<button
onClick={() => setActiveTab('cluster')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${activeTab === 'cluster' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Server size={18} className="mr-3" />
Cluster Monitor
</button>
{/* Future monitoring tabs (e.g., Application Logs, Agent Metrics) can go here */}
</div>
</div>
{/* Monitoring Main Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'cluster' && <MonitoringDashboard />}
</div>
</div>
);
}

View File

@ -35,7 +35,7 @@ async def start_system():
await postgres_database.init_db.remote()
# 3. 启动全局状态机
global_state_machine = GlobalStateMachine.options(name='global_state_machine').remote(postgres_database)
GlobalStateMachine.options(name='global_state_machine').remote(postgres_database)
# 4. 启动核心节点
supervisory_node = SupervisoryNode.options(name='supervisory_node').remote()

View File

@ -12,34 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import ray
import asyncio
from fastapi import APIRouter
cluster_router = APIRouter(prefix="/api/v1/cluster", tags=["cluster"])
@cluster_router.websocket("/ws/state")
async def update_cluster_state(websocket: WebSocket):
await websocket.accept()
try:
while True:
nodes = ray.nodes()
payload = [
{
"node_id": node.get("NodeID"),
"node_name": node.get("NodeName"),
"alive": node.get("Alive"),
"resources": node.get("Resources", {}),
"remaining": node.get("RemainingResources", {})
}
for node in nodes
]
await websocket.send_json(payload)
await asyncio.sleep(10)
except WebSocketDisconnect:
pass
except RuntimeError as e:
if "closed" not in str(e) and "GeneratorExit" not in str(e):
raise
except Exception:
pass
# Monitor websocket API temporarily removed

View File

@ -12,11 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from pretor.utils.access import Accessor, TokenData
from pretor.api.platform.event import PretorEvent
from pretor.utils.ray_hook import ray_actor_hook
import os
import shutil
from pretor.utils.logger import get_logger
logger = get_logger('frontend')
@ -45,3 +47,17 @@ async def create_message(message: Message,
else:
return {"message": message}
@client_router.post("/upload")
async def upload_file(file: UploadFile = File(...),
token_data: TokenData = Depends(Accessor.get_current_user)):
try:
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
logger.info(f"用户 {token_data.username} 上传了文件: {file.filename}")
return {"filename": file.filename, "message": f"File {file.filename} uploaded successfully"}
except Exception as e:
logger.error(f"文件上传失败: {e}")
raise HTTPException(status_code=500, detail="文件上传失败")

View File

@ -14,26 +14,37 @@
from pretor.utils.ray_hook import ray_actor_hook
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
import asyncio
workflow_router = APIRouter(prefix="/api/v1/workflow", tags=["workflow"])
@workflow_router.websocket("/ws/{trace_id}")
async def get_workflow(websocket: WebSocket, trace_id: str):
await websocket.accept()
global_state_machine = ray_actor_hook("global_state_machine")
try:
while True:
await websocket.send(await global_state_machine.get_workflow.remote(trace_id))
await websocket.send_text(await global_state_machine.get_pending.remote(trace_id))
response = await websocket.receive_text()
await global_state_machine.put_received(trace_id, response)
except WebSocketDisconnect:
pass
except RuntimeError as e:
if "closed" not in str(e) and "GeneratorExit" not in str(e):
raise
except Exception:
pass
@workflow_router.get("/sse/{trace_id}")
async def get_workflow_sse(trace_id: str, request: Request):
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
# You might also want to send the workflow state periodically or when updated
# Here we just wait for pending messages and send them
message = await global_state_machine.get_pending.remote(trace_id)
# Ensure the message is formatted as SSE
yield f"data: {message}\n\n"
except asyncio.CancelledError:
pass
return StreamingResponse(event_generator(), media_type="text/event-stream")
@workflow_router.post("/reply/{trace_id}")
async def post_workflow_reply(trace_id: str, request: Request):
data = await request.json()
reply_msg = data.get("message", "")
global_state_machine = ray_actor_hook("global_state_machine").global_state_machine
await global_state_machine.put_received.remote(trace_id, reply_msg)
return {"status": "ok"}

View File

@ -1,4 +1,3 @@
import pytest
from pretor.core.database.table.provider import Provider
def test_provider_table():

View File

@ -1,4 +1,3 @@
import pytest
from pretor.core.database.table.user import User
def test_user_table():

View File

@ -1,4 +1,3 @@
import pytest
from pretor.core.global_state_machine.model_provider.base_provider import Provider, ProviderArgs, ProviderStatus
def test_provider_status():

View File

@ -5,7 +5,6 @@ from pretor.core.global_state_machine.provider_manager import ProviderManager
@pytest.mark.asyncio
async def test_provider_manager_init():
from pretor.core.global_state_machine.provider_manager import ProviderManager
mock_postgres = MagicMock()
mock_provider1 = MagicMock()
mock_provider1.provider_title = "title1"

View File

@ -1,4 +1,3 @@
import pytest
from unittest.mock import patch, MagicMock
from pretor.core.workflow.workflow_template_generator.workflow_template_generator import WorkflowTemplateGenerator

View File

@ -1,4 +1,3 @@
import pytest
import json
from unittest.mock import MagicMock, patch, mock_open
from pathlib import Path

View File

@ -83,7 +83,6 @@ def test_decode_token_validation_error():
token = "valid.jwt.invalid.payload"
payload = {"wrong": "payload"}
import pydantic
from fastapi import HTTPException
with patch("jwt.decode", return_value=payload):