465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
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;
|
||
projectId?: 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, projectId }: Props) {
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
|
||
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
|
||
streamRef.current = null;
|
||
setStreaming(null);
|
||
});
|
||
return () => { unsub?.(); };
|
||
}, [chatId]);
|
||
|
||
// WS: agent streaming events — use ref to accumulate, throttle renders
|
||
const streamRef = useRef<StreamingState | null>(null);
|
||
const streamRafRef = useRef<number>(0);
|
||
|
||
useEffect(() => {
|
||
const flushStream = () => {
|
||
setStreaming(streamRef.current ? { ...streamRef.current } : null);
|
||
};
|
||
const scheduleFlush = () => {
|
||
if (!streamRafRef.current) {
|
||
streamRafRef.current = requestAnimationFrame(() => {
|
||
streamRafRef.current = 0;
|
||
flushStream();
|
||
});
|
||
}
|
||
};
|
||
|
||
const unsubStart = wsClient.on("agent.stream.start", (data: any) => {
|
||
if (data.chat_id !== chatId) return;
|
||
streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
||
flushStream();
|
||
});
|
||
const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => {
|
||
if (data.chat_id !== chatId) return;
|
||
const s = streamRef.current;
|
||
if (!s) {
|
||
streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
||
}
|
||
if (data.block_type === "thinking") {
|
||
streamRef.current!.thinking += data.text || "";
|
||
} else {
|
||
streamRef.current!.text += data.text || "";
|
||
}
|
||
scheduleFlush();
|
||
});
|
||
const unsubTool = wsClient.on("agent.stream.tool", (data: any) => {
|
||
if (data.chat_id !== chatId) return;
|
||
const s = streamRef.current;
|
||
if (!s) return;
|
||
const existing = s.tools.findIndex((t) => t.tool === data.tool);
|
||
if (existing >= 0) {
|
||
s.tools[existing] = { tool: data.tool, status: data.status };
|
||
} else {
|
||
s.tools.push({ tool: data.tool, status: data.status });
|
||
}
|
||
scheduleFlush();
|
||
});
|
||
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?.();
|
||
if (streamRafRef.current) cancelAnimationFrame(streamRafRef.current);
|
||
streamRef.current = null;
|
||
};
|
||
}, [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([]);
|
||
mentionMapRef.current.clear();
|
||
setSending(true);
|
||
try {
|
||
// Extract @mentions from text → resolve to member IDs
|
||
const mentionMatches = text.match(/@(\w+)/g);
|
||
const mentionSlugs = mentionMatches ? [...new Set(mentionMatches.map(m => m.slice(1)))] : [];
|
||
const mentions = mentionSlugs
|
||
.map(slug => mentionMapRef.current.get(slug))
|
||
.filter((id): id is string => !!id);
|
||
|
||
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>
|
||
)}
|
||
{msg.tool_log && msg.tool_log.length > 0 && (
|
||
<details className="ml-5 mb-1">
|
||
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||
🔧 Инструменты ({msg.tool_log.length})
|
||
</summary>
|
||
<div className="mt-1 text-xs text-[var(--muted)] bg-white/5 rounded p-2 max-h-[300px] overflow-y-auto space-y-1">
|
||
{msg.tool_log.map((t, i) => (
|
||
<div key={i} className="border-b border-white/10 pb-1 last:border-0">
|
||
<span className="font-mono text-blue-400">{t.name}</span>
|
||
{t.result && (
|
||
<div className="ml-3 text-[var(--muted)] truncate max-w-[500px]" title={t.result}>
|
||
→ {t.result.slice(0, 150)}{t.result.length > 150 ? '…' : ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</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
|
||
projectId={projectId || ""}
|
||
value={input}
|
||
onChange={setInput}
|
||
onSubmit={handleSend}
|
||
onMentionInsert={(id, slug) => mentionMapRef.current.set(slug, id)}
|
||
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>
|
||
);
|
||
}
|