feat: Add LangGraph streaming with real-time UI updates
- Add streaming schemas and events - Implement run_review_stream in ReviewerAgent - Update task_worker to broadcast streaming events via WebSocket - Create ReviewStream component for real-time progress visualization - Integrate ReviewStream into ReviewDetail page - Show agent steps, LLM messages, and progress in real-time
This commit is contained in:
224
frontend/src/components/ReviewStream.tsx
Normal file
224
frontend/src/components/ReviewStream.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { WS_URL } from '../api/websocket';
|
||||
|
||||
interface StreamEvent {
|
||||
type: string;
|
||||
review_id: number;
|
||||
pr_number: number;
|
||||
timestamp: string;
|
||||
data: {
|
||||
type?: string;
|
||||
step?: string;
|
||||
message?: string;
|
||||
data?: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReviewStreamProps {
|
||||
reviewId: number;
|
||||
}
|
||||
|
||||
const STEP_NAMES: Record<string, { name: string; icon: string }> = {
|
||||
fetch_pr_info: { name: 'Получение информации о PR', icon: '📋' },
|
||||
fetch_files: { name: 'Загрузка файлов', icon: '📂' },
|
||||
analyze_files: { name: 'Анализ кода', icon: '🔍' },
|
||||
post_comments: { name: 'Публикация комментариев', icon: '💬' },
|
||||
complete_review: { name: 'Завершение review', icon: '✅' },
|
||||
};
|
||||
|
||||
export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
||||
const [events, setEvents] = useState<StreamEvent[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<string>('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [llmMessages, setLlmMessages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for streaming');
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: StreamEvent = JSON.parse(event.data);
|
||||
|
||||
// Filter events for this review
|
||||
if (data.review_id === reviewId) {
|
||||
setEvents((prev) => [...prev, data]);
|
||||
|
||||
// Update current step
|
||||
if (data.data.type === 'agent_step') {
|
||||
setCurrentStep(data.data.step || '');
|
||||
}
|
||||
|
||||
// Collect LLM messages
|
||||
if (data.data.type === 'llm_message') {
|
||||
setLlmMessages((prev) => [...prev, data.data.message || '']);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [reviewId]);
|
||||
|
||||
const getStepInfo = (step: string) => {
|
||||
return STEP_NAMES[step] || { name: step, icon: '⚙️' };
|
||||
};
|
||||
|
||||
const renderStepProgress = () => {
|
||||
const steps = Object.keys(STEP_NAMES);
|
||||
const currentIndex = steps.indexOf(currentStep);
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{steps.map((step, index) => {
|
||||
const stepInfo = getStepInfo(step);
|
||||
const isActive = index === currentIndex;
|
||||
const isCompleted = index < currentIndex;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center flex-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white scale-110'
|
||||
: isCompleted
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-dark-card border-2 border-dark-border text-dark-text-muted'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{stepInfo.icon}</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 transition-all ${
|
||||
isCompleted ? 'bg-green-600' : 'bg-dark-border'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{currentStep && (
|
||||
<div className="text-center text-dark-text-primary font-medium mt-2">
|
||||
{getStepInfo(currentStep).name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEvents = () => {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-dark-text-muted py-4">
|
||||
Ожидание событий...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{events.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-dark-text-secondary text-xs">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-xs bg-blue-900/30 text-blue-400 px-2 py-1 rounded">
|
||||
{event.data.type}
|
||||
</span>
|
||||
</div>
|
||||
{event.data.step && (
|
||||
<div className="text-dark-text-primary">
|
||||
{getStepInfo(event.data.step).icon} {getStepInfo(event.data.step).name}
|
||||
</div>
|
||||
)}
|
||||
{event.data.message && (
|
||||
<div className="text-dark-text-muted mt-1 text-xs">
|
||||
{event.data.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLLMMessages = () => {
|
||||
if (llmMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold text-dark-text-primary mb-2 flex items-center gap-2">
|
||||
🤖 Ответы LLM
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{llmMessages.slice(-5).map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-dark-card border border-dark-border rounded-lg p-2 text-xs text-dark-text-secondary font-mono"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-bg rounded-lg p-6 border border-dark-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-dark-text-primary">
|
||||
🔄 Процесс Review
|
||||
</h2>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
|
||||
isConnected
|
||||
? 'bg-green-900/30 text-green-400'
|
||||
: 'bg-red-900/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'
|
||||
}`} />
|
||||
{isConnected ? 'Подключено' : 'Отключено'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderStepProgress()}
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-text-primary mb-3">
|
||||
📝 События
|
||||
</h3>
|
||||
{renderEvents()}
|
||||
</div>
|
||||
|
||||
{renderLLMMessages()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getReview, retryReview } from '../api/client';
|
||||
import { wsClient } from '../api/websocket';
|
||||
import ReviewProgress from '../components/ReviewProgress';
|
||||
import CommentsList from '../components/CommentsList';
|
||||
import { ReviewStream } from '../components/ReviewStream';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
@@ -127,6 +128,11 @@ export default function ReviewDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Review Stream (only during review) */}
|
||||
{['pending', 'fetching', 'analyzing', 'commenting'].includes(review.status) && (
|
||||
<ReviewStream reviewId={review.id} />
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Прогресс</h2>
|
||||
|
||||
Reference in New Issue
Block a user