upload
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user