Phase 1: streaming UI + thinking blocks in ChatPanel
- Agent streaming: real-time text deltas in chat - Thinking/reasoning: collapsible block with agent's thinking process - Tool call indicators: shows running/completed tools - Streaming bubble with pulse animation while agent generates - Message type updated with 'thinking' field
This commit is contained in:
parent
92db42113d
commit
d5eaa13b21
@ -12,6 +12,14 @@ const AUTHOR_ICON: Record<string, string> = {
|
|||||||
system: "⚙️",
|
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 {
|
interface Props {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
projectSlug?: string;
|
projectSlug?: string;
|
||||||
@ -35,6 +43,7 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [streaming, setStreaming] = useState<StreamingState | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -62,10 +71,54 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
|
// Clear streaming when final message arrives
|
||||||
|
setStreaming(null);
|
||||||
});
|
});
|
||||||
return () => { unsub?.(); };
|
return () => { unsub?.(); };
|
||||||
}, [chatId]);
|
}, [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)
|
// Auto-scroll on new messages (only if near bottom)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitial.current) {
|
if (isInitial.current) {
|
||||||
@ -230,6 +283,16 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
</div>
|
</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>
|
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
||||||
{renderAttachments(msg.attachments)}
|
{renderAttachments(msg.attachments)}
|
||||||
</div>
|
</div>
|
||||||
@ -250,9 +313,46 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
||||||
)}
|
)}
|
||||||
{messages.map(renderMessage)}
|
{messages.map(renderMessage)}
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && !streaming && (
|
||||||
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
<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 ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -146,6 +146,7 @@ export interface Message {
|
|||||||
author_id: string | null;
|
author_id: string | null;
|
||||||
author: MemberBrief | null;
|
author: MemberBrief | null;
|
||||||
content: string;
|
content: string;
|
||||||
|
thinking: string | null;
|
||||||
mentions: string[];
|
mentions: string[];
|
||||||
voice_url: string | null;
|
voice_url: string | null;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user