import { useCallback, useEffect, useRef, useState } from "react"; import type { Message, UploadedFile } from "@/lib/api"; import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api"; import { wsClient } from "@/lib/ws"; import MentionInput from "./MentionInput"; const PAGE_SIZE = 30; const AUTHOR_ICON: Record = { human: "👤", agent: "🤖", system: "⚙️", }; /** Streaming state for agent that is currently generating */ interface StreamingState { agentSlug: string; text: string; thinking: string; tools: Array<{ tool: string; status: string }>; } interface Props { chatId: string; projectSlug?: string; } function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function isImageMime(mime: string | null): boolean { return !!mime && mime.startsWith("image/"); } export default function ChatPanel({ chatId, projectSlug }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [hasMore, setHasMore] = useState(true); const [pendingFiles, setPendingFiles] = useState([]); const [uploading, setUploading] = useState(false); const [streaming, setStreaming] = useState(null); const scrollRef = useRef(null); const bottomRef = useRef(null); const fileInputRef = useRef(null); const isInitial = useRef(true); // Load initial messages useEffect(() => { if (!chatId) return; isInitial.current = true; setHasMore(true); getMessages({ chat_id: chatId, limit: PAGE_SIZE }) .then((msgs) => { setMessages(msgs); setHasMore(msgs.length >= PAGE_SIZE); setTimeout(() => bottomRef.current?.scrollIntoView(), 50); }) .catch(() => {}); }, [chatId]); // WS: new messages useEffect(() => { const unsub = wsClient.on("message.new", (msg: Message) => { if (msg.chat_id !== chatId) return; setMessages((prev) => { if (prev.some((m) => m.id === msg.id)) return prev; return [...prev, msg]; }); // Clear streaming when final message arrives setStreaming(null); }); return () => { unsub?.(); }; }, [chatId]); // WS: agent streaming events useEffect(() => { const unsubStart = wsClient.on("agent.stream.start", (data: any) => { if (data.chat_id !== chatId) return; setStreaming({ agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] }); }); const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => { if (data.chat_id !== chatId) return; setStreaming((prev) => { if (!prev) return { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] }; if (data.block_type === "thinking") { return { ...prev, thinking: prev.thinking + (data.text || "") }; } return { ...prev, text: prev.text + (data.text || "") }; }); }); const unsubTool = wsClient.on("agent.stream.tool", (data: any) => { if (data.chat_id !== chatId) return; setStreaming((prev) => { if (!prev) return null; const tools = [...prev.tools]; const existing = tools.findIndex((t) => t.tool === data.tool); if (existing >= 0) { tools[existing] = { tool: data.tool, status: data.status }; } else { tools.push({ tool: data.tool, status: data.status }); } return { ...prev, tools }; }); }); const unsubEnd = wsClient.on("agent.stream.end", (data: any) => { if (data.chat_id !== chatId) return; // Don't clear immediately — wait for message.new with final content }); return () => { unsubStart?.(); unsubDelta?.(); unsubTool?.(); unsubEnd?.(); }; }, [chatId]); // Auto-scroll on new messages (only if near bottom) useEffect(() => { if (isInitial.current) { isInitial.current = false; return; } const el = scrollRef.current; if (!el) return; if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } }, [messages]); // Load older messages const loadOlder = useCallback(async () => { if (loadingOlder || !hasMore || !chatId || messages.length === 0) return; setLoadingOlder(true); const el = scrollRef.current; const prevHeight = el?.scrollHeight || 0; try { const older = await getMessages({ chat_id: chatId, limit: PAGE_SIZE, offset: messages.length }); if (older.length < PAGE_SIZE) setHasMore(false); if (older.length > 0) { setMessages((prev) => { const ids = new Set(prev.map((m) => m.id)); return [...older.filter((m) => !ids.has(m.id)), ...prev]; }); requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight; }); } } catch (e) { console.error("Failed to load older messages:", e); } finally { setLoadingOlder(false); } }, [chatId, messages.length, loadingOlder, hasMore]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder(); }, [loadOlder, hasMore, loadingOlder]); const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files?.length) return; setUploading(true); try { const uploaded: UploadedFile[] = []; for (const file of Array.from(files)) { const result = await uploadFile(file); uploaded.push(result); } setPendingFiles((prev) => [...prev, ...uploaded]); } catch (err) { console.error("Upload failed:", err); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } }; const removePendingFile = (fileId: string) => { setPendingFiles((prev) => prev.filter((f) => f.file_id !== fileId)); }; const handleSend = async () => { if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return; const text = input.trim() || (pendingFiles.length > 0 ? `📎 ${pendingFiles.map(f => f.filename).join(", ")}` : ""); const attachments = pendingFiles.map((f) => ({ file_id: f.file_id, filename: f.filename, mime_type: f.mime_type, size: f.size, storage_name: f.storage_name, })); setInput(""); setPendingFiles([]); setSending(true); try { // Extract @mentions from text const mentionMatches = text.match(/@(\w+)/g); const mentions = mentionMatches ? [...new Set(mentionMatches.map(m => m.slice(1)))] : []; const msg = await sendMessage({ chat_id: chatId, content: text, mentions: mentions.length > 0 ? mentions : undefined, attachments: attachments.length > 0 ? attachments : undefined, }); setMessages((prev) => { if (prev.some((m) => m.id === msg.id)) return prev; return [...prev, msg]; }); setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50); } catch (e) { console.error("Failed to send:", e); setInput(text); } finally { setSending(false); } }; const renderContent = (text: string) => { const parts = text.split(/(@\w+)/g); return parts.map((part, i) => /^@\w+$/.test(part) ? ( {part} ) : ( {part} ) ); }; const renderAttachments = (attachments: Message["attachments"]) => { if (!attachments?.length) return null; return (
{attachments.map((att) => { const url = getAttachmentUrl(att.id); if (isImageMime(att.mime_type)) { return ( {att.filename} ); } return ( 📎 {att.filename} {formatFileSize(att.size)} ); })}
); }; const renderMessage = (msg: Message) => { const bgStyle = msg.author_type === "system" ? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" } : msg.author_type === "agent" ? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" } : undefined; return (
{AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_type === "system" ? (msg.actor ? `Система (${msg.actor.name})` : "Система") : (msg.author?.name || msg.author?.slug || "Unknown")} · {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
{msg.thinking && (
💭 Размышления
{msg.thinking}
)}
{renderContent(msg.content)}
{renderAttachments(msg.attachments)}
); }; return (
{loadingOlder && (
Загрузка...
)} {!hasMore && messages.length > 0 && (
— начало истории —
)} {messages.map(renderMessage)} {messages.length === 0 && !streaming && (
Нет сообщений
)} {/* Streaming indicator */} {streaming && (
🤖 {streaming.agentSlug}
{streaming.thinking && (
💭 Размышления
{streaming.thinking}
)} {streaming.tools.length > 0 && (
{streaming.tools.map((t, i) => ( {t.status === "running" ? "🔧" : "✅"} {t.tool} ))}
)} {streaming.text && (
{streaming.text}
)} {!streaming.text && streaming.tools.length === 0 && !streaming.thinking && (
Думает...
)}
)}
{/* Pending files preview */} {pendingFiles.length > 0 && (
{pendingFiles.map((f) => (
📎 {f.filename} {formatFileSize(f.size)}
))}
)}
); }