web-client-vite/src/components/ChatPanel.tsx

420 lines
15 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 { 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<string, string> = {
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<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const [streaming, setStreaming] = useState<StreamingState | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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) ? (
<span key={i} className="text-[var(--accent)] font-medium">{part}</span>
) : (
<span key={i}>{part}</span>
)
);
};
const renderAttachments = (attachments: Message["attachments"]) => {
if (!attachments?.length) return null;
return (
<div className="ml-5 mt-1 flex flex-wrap gap-2">
{attachments.map((att) => {
const url = getAttachmentUrl(att.id);
if (isImageMime(att.mime_type)) {
return (
<a key={att.id} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={att.filename}
className="max-w-[300px] max-h-[200px] rounded border border-[var(--border)] object-cover"
/>
</a>
);
}
return (
<a
key={att.id}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded border border-[var(--border)] bg-[var(--bg)] hover:bg-white/5 text-sm"
>
<span>📎</span>
<span className="truncate max-w-[200px]">{att.filename}</span>
<span className="text-[var(--muted)] text-xs">{formatFileSize(att.size)}</span>
</a>
);
})}
</div>
);
};
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 (
<div key={msg.id} className="text-sm" style={bgStyle}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
<span className="font-medium">
{msg.author_type === "system"
? (msg.actor ? `Система (${msg.actor.name})` : "Система")
: (msg.author?.name || msg.author?.slug || "Unknown")}
</span>
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
{msg.thinking && (
<details className="ml-5 mb-1">
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{msg.thinking}
</div>
</details>
)}
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
{renderAttachments(msg.attachments)}
</div>
);
};
return (
<div className="flex flex-col h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
>
{loadingOlder && (
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
)}
{!hasMore && messages.length > 0 && (
<div className="text-center text-xs text-[var(--muted)] py-2"> начало истории </div>
)}
{messages.map(renderMessage)}
{messages.length === 0 && !streaming && (
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
)}
{/* Streaming indicator */}
{streaming && (
<div className="text-sm" style={{ backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>🤖</span>
<span className="font-medium">{streaming.agentSlug}</span>
<span className="animate-pulse"></span>
</div>
{streaming.thinking && (
<details className="ml-5 mb-1" open>
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
💭 Размышления
</summary>
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
{streaming.thinking}
</div>
</details>
)}
{streaming.tools.length > 0 && (
<div className="ml-5 mb-1 text-xs text-[var(--muted)]">
{streaming.tools.map((t, i) => (
<span key={i} className="mr-2">
{t.status === "running" ? "🔧" : "✅"} {t.tool}
</span>
))}
</div>
)}
{streaming.text && (
<div className="ml-5 whitespace-pre-wrap">{streaming.text}</div>
)}
{!streaming.text && streaming.tools.length === 0 && !streaming.thinking && (
<div className="ml-5 text-[var(--muted)] italic">Думает...</div>
)}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Pending files preview */}
{pendingFiles.length > 0 && (
<div className="px-6 py-2 border-t border-[var(--border)] flex flex-wrap gap-2">
{pendingFiles.map((f) => (
<div
key={f.file_id}
className="flex items-center gap-1 px-2 py-1 rounded bg-[var(--accent)]/10 text-xs"
>
<span>📎</span>
<span className="truncate max-w-[150px]">{f.filename}</span>
<span className="text-[var(--muted)]">{formatFileSize(f.size)}</span>
<button
onClick={() => removePendingFile(f.file_id)}
className="ml-1 text-[var(--muted)] hover:text-red-400"
>
</button>
</div>
))}
</div>
)}
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2 items-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-2 py-2 text-[var(--muted)] hover:text-[var(--fg)] transition-colors shrink-0 disabled:opacity-50"
title="Прикрепить файл"
>
{uploading ? "⏳" : "📎"}
</button>
<MentionInput
projectSlug={projectSlug || ""}
value={input}
onChange={setInput}
onSubmit={handleSend}
placeholder="Сообщение... (@mention)"
disabled={sending}
/>
<button
onClick={handleSend}
disabled={sending}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
>
</button>
</div>
</div>
);
}