feat: chat panel above kanban, WS client, chat API
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s

This commit is contained in:
Markov 2026-02-15 23:07:14 +01:00
parent 96d1fabc4c
commit b89969a7a0
5 changed files with 337 additions and 4 deletions

View File

@ -138,9 +138,33 @@ async def list_agents(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/agents/") return await tracker_get("/api/v1/agents/")
@app.get("/api/v1/agents/adapters") # --- Proxy: Chats ---
async def list_adapters(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/agents/adapters") @app.get("/api/v1/chats/lobby")
async def get_lobby(user: dict = Depends(get_current_user)):
return await tracker_get("/api/v1/chats/lobby")
@app.get("/api/v1/chats/project/{project_id}")
async def get_project_chat(project_id: str, user: dict = Depends(get_current_user)):
return await tracker_get(f"/api/v1/chats/project/{project_id}")
@app.get("/api/v1/chats/{chat_id}/messages")
async def list_messages(chat_id: str, limit: int = 50, before: str = None, user: dict = Depends(get_current_user)):
params = {"limit": limit}
if before:
params["before"] = before
return await tracker_get(f"/api/v1/chats/{chat_id}/messages", params=params)
@app.post("/api/v1/chats/{chat_id}/messages")
async def create_message(chat_id: str, request: Request, user: dict = Depends(get_current_user)):
body = await request.json()
# Inject sender info from auth
body.setdefault("sender_type", "human")
body.setdefault("sender_name", user.get("name", "human"))
return await tracker_post(f"/api/v1/chats/{chat_id}/messages", json=body)
# --- Proxy: Labels --- # --- Proxy: Labels ---

View File

@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { getProjects, Project } from "@/lib/api"; import { getProjects, Project } from "@/lib/api";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import KanbanBoard from "@/components/KanbanBoard"; import KanbanBoard from "@/components/KanbanBoard";
import ChatPanel from "@/components/ChatPanel";
export default function ProjectPage() { export default function ProjectPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
@ -40,6 +41,7 @@ export default function ProjectPage() {
)} )}
</div> </div>
</header> </header>
<ChatPanel projectId={project.id} />
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<KanbanBoard projectId={project.id} /> <KanbanBoard projectId={project.id} />
</div> </div>

View File

@ -0,0 +1,195 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Chat,
ChatMessage,
getProjectChat,
getChatMessages,
sendChatMessage,
} from "@/lib/api";
import { wsClient } from "@/lib/ws";
interface ChatPanelProps {
projectId: string;
}
export default function ChatPanel({ projectId }: ChatPanelProps) {
const [chat, setChat] = useState<Chat | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [expanded, setExpanded] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Load chat and messages
useEffect(() => {
let cancelled = false;
getProjectChat(projectId).then((c) => {
if (cancelled) return;
setChat(c);
getChatMessages(c.id).then((msgs) => {
if (!cancelled) setMessages(msgs);
});
});
return () => { cancelled = true; };
}, [projectId]);
// WebSocket: subscribe to chat room
useEffect(() => {
if (!chat) return;
wsClient.connect();
wsClient.subscribeChat(chat.id);
const unsub = wsClient.on("chat.message", (msg: any) => {
if (msg.chat_id === chat.id) {
setMessages((prev) => [
...prev,
{
id: msg.id,
chat_id: msg.chat_id,
sender_type: msg.sender_type,
sender_id: msg.sender_id,
sender_name: msg.sender_name,
content: msg.content,
created_at: msg.created_at,
},
]);
}
});
return () => {
wsClient.unsubscribeChat(chat.id);
unsub();
};
}, [chat]);
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async () => {
if (!chat || !input.trim() || sending) return;
const text = input.trim();
setInput("");
setSending(true);
try {
// Send via REST (gets saved + we get it back via WS broadcast)
const msg = await sendChatMessage(chat.id, text);
// Add immediately (WS might duplicate — deduplicate by id)
setMessages((prev) => {
if (prev.find((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
} catch (e) {
console.error("Failed to send message:", e);
setInput(text); // restore on failure
} finally {
setSending(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (collapsed) {
return (
<div className="border-b border-[var(--border)] px-4 py-2 flex items-center justify-between bg-[var(--bg-secondary)]">
<button
onClick={() => setCollapsed(false)}
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] flex items-center gap-2"
>
💬 Чат проекта
{messages.length > 0 && (
<span className="bg-[var(--accent)] text-white text-xs px-1.5 py-0.5 rounded-full">
{messages.length}
</span>
)}
</button>
</div>
);
}
const panelHeight = expanded ? "h-full" : "h-[30vh]";
return (
<div className={`${panelHeight} border-b border-[var(--border)] flex flex-col bg-[var(--bg-secondary)] transition-all duration-200`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--border)]">
<span className="text-sm font-medium">💬 Чат проекта</span>
<div className="flex items-center gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title={expanded ? "Свернуть" : "Развернуть"}
>
{expanded ? "⬆️" : "⬇️"}
</button>
<button
onClick={() => { setCollapsed(true); setExpanded(false); }}
className="text-[var(--muted)] hover:text-[var(--fg)] p-1 text-sm"
title="Скрыть"
>
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
{messages.length === 0 && (
<p className="text-sm text-[var(--muted)] text-center py-4">
Пока пусто. Напишите первое сообщение 💬
</p>
)}
{messages.map((msg) => (
<div key={msg.id} className="flex gap-2 text-sm">
<span className={`font-medium shrink-0 ${
msg.sender_type === "agent" ? "text-blue-400" :
msg.sender_type === "system" ? "text-yellow-500" :
"text-green-400"
}`}>
{msg.sender_name || msg.sender_type}:
</span>
<span className="text-[var(--fg)] break-words">{msg.content}</span>
<span className="text-[var(--muted)] text-xs shrink-0 self-end">
{new Date(msg.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Написать сообщение..."
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
disabled={!chat || sending}
/>
<button
onClick={handleSend}
disabled={!chat || !input.trim() || sending}
className="bg-[var(--accent)] text-white px-4 py-1.5 rounded text-sm hover:opacity-90 disabled:opacity-50"
>
</button>
</div>
</div>
);
}

View File

@ -61,7 +61,7 @@ export interface Agent {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
adapter_id: string; capabilities: string[];
subscription_mode: string; subscription_mode: string;
max_concurrent: number; max_concurrent: number;
status: string; status: string;
@ -93,5 +93,34 @@ export const deleteTask = (id: string) =>
// Agents // Agents
export const getAgents = () => request<Agent[]>("/api/v1/agents/"); export const getAgents = () => request<Agent[]>("/api/v1/agents/");
// Chats
export interface Chat {
id: string;
project_id: string | null;
task_id: string | null;
kind: string;
}
export interface ChatMessage {
id: string;
chat_id: string;
sender_type: string;
sender_id: string | null;
sender_name: string | null;
content: string;
created_at: string;
}
export const getLobbyChat = () => request<Chat>("/api/v1/chats/lobby");
export const getProjectChat = (projectId: string) =>
request<Chat>(`/api/v1/chats/project/${projectId}`);
export const getChatMessages = (chatId: string, limit = 50) =>
request<ChatMessage[]>(`/api/v1/chats/${chatId}/messages?limit=${limit}`);
export const sendChatMessage = (chatId: string, content: string) =>
request<ChatMessage>(`/api/v1/chats/${chatId}/messages`, {
method: "POST",
body: JSON.stringify({ content }),
});
// Labels // Labels
export const getLabels = () => request<Label[]>("/api/v1/labels/"); export const getLabels = () => request<Label[]>("/api/v1/labels/");

83
src/lib/ws.ts Normal file
View File

@ -0,0 +1,83 @@
/**
* WebSocket client for Team Board real-time features.
* Connects to BFF WebSocket which proxies to Tracker.
*/
type WsCallback = (data: any) => void;
class WsClient {
private ws: WebSocket | null = null;
private listeners: Map<string, Set<WsCallback>> = new Map();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private subscribedRooms: Set<string> = new Set();
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
const token = typeof window !== "undefined" ? localStorage.getItem("tb_token") : null;
if (!token) return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${window.location.host}/ws?token=${token}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("[WS] connected");
// Re-subscribe to rooms
for (const room of this.subscribedRooms) {
this.send("chat.subscribe", { chat_id: room });
}
};
this.ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
const event = msg.event;
if (event) {
const cbs = this.listeners.get(event);
if (cbs) cbs.forEach((cb) => cb(msg));
}
} catch {}
};
this.ws.onclose = () => {
console.log("[WS] disconnected, reconnecting in 3s...");
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
};
this.ws.onerror = () => {
this.ws?.close();
};
}
disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
}
send(event: string, data: Record<string, any> = {}) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ event, ...data }));
}
}
subscribeChat(chatId: string) {
this.subscribedRooms.add(chatId);
this.send("chat.subscribe", { chat_id: chatId });
}
unsubscribeChat(chatId: string) {
this.subscribedRooms.delete(chatId);
this.send("chat.unsubscribe", { chat_id: chatId });
}
on(event: string, cb: WsCallback) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(cb);
return () => this.listeners.get(event)?.delete(cb);
}
}
export const wsClient = new WsClient();