Files
prod-end-2026/frontend/src/components/shared/SynthesisChat.tsx
T
2026-03-17 18:32:44 +03:00

418 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bot, RotateCcw, Send, Sparkles, User, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { cn, generateUUID } from '@/lib/utils';
import {
generatePipeline,
getPipelineDialogHistory,
listPipelineDialogs,
type GeneratePipelineResponse,
} from '@/api/chat';
import { usePipelineContext } from '@/contexts/PipelineContext';
import { useAuth } from '@/contexts/AuthContext';
import { useQueryClient } from '@tanstack/react-query';
interface Message {
role: 'user' | 'assistant';
content: string;
isGenerating?: boolean;
}
interface SynthesisChatProps {
onSynthesize?: (prompt: string) => void;
onClose?: () => void;
className?: string;
initialMessage?: string;
initialDialogId?: string;
}
const DEFAULT_ASSISTANT_MESSAGE =
'Привет! Я помогу собрать Pipeline. Опишите бизнес-задачу, которую хотите автоматизировать.';
const ASSISTANT_THINKING_MESSAGE =
'Анализирую возможности... Подбираю нужные Capabilities.';
const isPipelineReady = (payload: GeneratePipelineResponse | null | undefined) => {
if (!payload) {
return false;
}
return (
(payload.status === 'ready' || payload.status === 'success') &&
Array.isArray(payload.nodes) &&
payload.nodes.length > 0
);
};
const buildDialogStorageKey = (userId: string | undefined) =>
userId ? `pipeline_active_dialog_id:${userId}` : 'pipeline_active_dialog_id:anonymous';
export const SynthesisChat: React.FC<SynthesisChatProps> = ({
onSynthesize,
onClose,
className,
initialMessage,
initialDialogId,
}) => {
const { setPipeline, setIsHydrating: setContextHydrating } = usePipelineContext();
const { user } = useAuth();
const queryClient = useQueryClient();
const [messages, setMessages] = useState<Message[]>([
{
role: 'assistant',
content: DEFAULT_ASSISTANT_MESSAGE,
},
]);
const [inputValue, setInputValue] = useState('');
const [dialogId, setDialogId] = useState<string | null>(initialDialogId || null);
const [isTyping, setIsTyping] = useState(false);
const [isHydrating, setIsHydrating] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const initialMessageProcessed = useRef(false);
const storageKey = useMemo(() => buildDialogStorageKey(user?.id), [user?.id]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
useEffect(() => {
initialMessageProcessed.current = false;
}, [initialDialogId, initialMessage]);
useEffect(() => {
let cancelled = false;
const hydrateDialog = async () => {
setIsHydrating(true);
setContextHydrating(true);
let activeDialogId: string | null = null;
let shouldLoadHistory = false;
const storedDialogId = localStorage.getItem(storageKey);
let dialogs: Array<{ dialog_id: string }> = [];
let dialogsLoaded = false;
try {
dialogs = await listPipelineDialogs(50, 0);
dialogsLoaded = true;
} catch (error) {
console.error('Unable to load dialogs list:', error);
}
if (initialDialogId) {
activeDialogId = initialDialogId;
// Even if initialMessage is present, someone might have refreshed the page.
// We should try to load history first to see if the message was already sent.
shouldLoadHistory = true;
} else if (storedDialogId) {
if (!dialogsLoaded || dialogs.some((dialog) => dialog.dialog_id === storedDialogId)) {
activeDialogId = storedDialogId;
shouldLoadHistory = true;
}
} else if (dialogs.length > 0) {
activeDialogId = dialogs[0].dialog_id;
shouldLoadHistory = true;
} else {
activeDialogId = generateUUID();
}
if (cancelled) {
return;
}
setDialogId(activeDialogId);
localStorage.setItem(storageKey, activeDialogId);
if (!shouldLoadHistory) {
setMessages([{ role: 'assistant', content: DEFAULT_ASSISTANT_MESSAGE }]);
// Don't reset pipeline immediately if we are switching to a new dialog,
// but only if it's truly a fresh state
if (!initialMessage) {
setPipeline(null);
}
setIsHydrating(false);
setContextHydrating(false);
return;
}
try {
const history = await getPipelineDialogHistory(activeDialogId, 30, 0);
if (cancelled) {
return;
}
if (history.messages.length > 0) {
setMessages(
history.messages.map((message) => ({
role: message.role,
content: message.content,
}))
);
const latestAssistantWithPayload = [...history.messages]
.reverse()
.find((message) => message.role === 'assistant' && message.assistant_payload);
const payload = latestAssistantWithPayload?.assistant_payload || null;
if (isPipelineReady(payload)) {
setPipeline({
status: payload.status,
message_ru: payload.message_ru,
chat_reply_ru: payload.chat_reply_ru || payload.message_ru,
pipeline_id: payload.pipeline_id,
nodes: payload.nodes,
edges: payload.edges,
missing_requirements: payload.missing_requirements || [],
context_summary: payload.context_summary,
});
} else {
setPipeline(null);
}
} else {
setMessages([{ role: 'assistant', content: DEFAULT_ASSISTANT_MESSAGE }]);
// History is empty, but if we have initialMessage, handleSend will call setPipeline soon.
if (!initialMessage) {
setPipeline(null);
}
}
} catch (error) {
if (!cancelled) {
// Preserve selected dialog ID and show an empty state instead of forcing a new dialog.
setMessages([{ role: 'assistant', content: DEFAULT_ASSISTANT_MESSAGE }]);
if (!initialMessage) {
setPipeline(null);
}
}
} finally {
if (!cancelled) {
setIsHydrating(false);
setContextHydrating(false);
}
}
};
hydrateDialog();
return () => {
cancelled = true;
};
}, [initialDialogId, initialMessage, setPipeline, storageKey]);
const handleSend = useCallback(
async (overrideValue?: string) => {
const valueToSend = overrideValue || inputValue;
if (!valueToSend.trim() || isTyping || isHydrating) {
return;
}
let activeDialogId = dialogId;
if (!activeDialogId) {
activeDialogId = generateUUID();
setDialogId(activeDialogId);
localStorage.setItem(storageKey, activeDialogId);
}
const userMessage = valueToSend;
setMessages((prev) => [
...prev,
{ role: 'user', content: userMessage },
{
role: 'assistant',
content: ASSISTANT_THINKING_MESSAGE,
isGenerating: true,
},
]);
setInputValue('');
setIsTyping(true);
try {
const response = await generatePipeline({
dialog_id: activeDialogId,
message: userMessage,
capability_ids: null,
});
setMessages((prev) => {
const newMessages = [...prev];
const lastIndex = newMessages.length - 1;
newMessages[lastIndex] = {
role: 'assistant',
content:
response.chat_reply_ru ||
response.message_ru ||
(response.status === 'ready'
? 'Я подготовил Pipeline для вашей задачи.'
: 'Произошла ошибка при генерации.'),
isGenerating: false,
};
return newMessages;
});
// Invalidate history list to show new dialog
queryClient.invalidateQueries({ queryKey: ["pipelineDialogs"] });
if (isPipelineReady(response)) {
setPipeline({
status: response.status,
message_ru: response.message_ru,
chat_reply_ru: response.chat_reply_ru || response.message_ru,
pipeline_id: response.pipeline_id,
nodes: response.nodes,
edges: response.edges,
missing_requirements: response.missing_requirements || [],
context_summary: response.context_summary,
});
} else {
setPipeline(null);
}
if ((response.status === 'ready' || response.status === 'success') && onSynthesize) {
onSynthesize(userMessage);
}
} catch (error) {
console.error('Error in chat:', error);
setPipeline(null);
setMessages((prev) => {
const newMessages = [...prev];
const lastIndex = newMessages.length - 1;
newMessages[lastIndex] = {
role: 'assistant',
content:
'К сожалению, произошла ошибка при сборке пайплайна. Попробуйте перефразировать запрос.',
isGenerating: false,
};
return newMessages;
});
} finally {
setIsTyping(false);
}
},
[dialogId, inputValue, isHydrating, isTyping, onSynthesize, setPipeline, storageKey]
);
useEffect(() => {
if (!initialMessage || isHydrating || initialMessageProcessed.current) {
return;
}
// Only send the initial message if we have no real messages yet (history is empty)
const hasRealMessages =
messages.length > 1 ||
(messages.length === 1 && messages[0].content !== DEFAULT_ASSISTANT_MESSAGE);
if (!hasRealMessages) {
initialMessageProcessed.current = true;
handleSend(initialMessage);
}
}, [handleSend, initialMessage, isHydrating, messages]);
const handleStartNewChat = () => {
const newDialogId = generateUUID();
setDialogId(newDialogId);
localStorage.setItem(storageKey, newDialogId);
setMessages([{ role: 'assistant', content: DEFAULT_ASSISTANT_MESSAGE }]);
setPipeline(null);
setInputValue('');
};
return (
<div className={cn('flex flex-col h-full bg-card border-l border-border', className)}>
<div className="p-4 border-b border-border flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm text-foreground">Synthesis Chat</h3>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] bg-primary/10 text-primary border-primary/20">
AI ASSISTANT
</Badge>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={cn('flex gap-3 max-w-[90%]', msg.role === 'user' ? 'ml-auto flex-row-reverse' : '')}
>
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center shrink-0 border border-border',
msg.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
)}
>
{msg.role === 'assistant' ? (
<Bot className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4" />
)}
</div>
<div
className={cn(
'p-3 rounded-2xl text-sm leading-relaxed shadow-sm border',
msg.role === 'assistant'
? 'bg-card border-border text-foreground'
: 'bg-primary text-primary-foreground border-primary'
)}
>
{msg.content}
{msg.isGenerating && (
<span className="inline-flex ml-2 gap-1">
<span className="w-1 h-1 bg-primary rounded-full animate-bounce" />
<span className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:0.2s]" />
<span className="w-1 h-1 bg-primary rounded-full animate-bounce [animation-delay:0.4s]" />
</span>
)}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="p-4 border-t border-border space-y-3">
{messages.length > 1 && (
<div className="flex gap-2 mb-2">
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] gap-1 border-border"
onClick={handleStartNewChat}
>
<RotateCcw className="h-3 w-3" /> Новый чат
</Button>
</div>
)}
<div className="relative">
<Input
placeholder="Опишите задачу..."
className="pr-12 bg-background border-border h-11"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
disabled={isTyping || isHydrating}
/>
<Button
size="sm"
className="absolute right-1 top-1 h-9 w-9 p-0 bg-primary hover:bg-primary/90"
onClick={() => handleSend()}
disabled={isTyping || isHydrating}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};