diff --git a/bff/app.py b/bff/app.py index 50a5dbf..f454ffe 100644 --- a/bff/app.py +++ b/bff/app.py @@ -138,9 +138,33 @@ async def list_agents(user: dict = Depends(get_current_user)): return await tracker_get("/api/v1/agents/") -@app.get("/api/v1/agents/adapters") -async def list_adapters(user: dict = Depends(get_current_user)): - return await tracker_get("/api/v1/agents/adapters") +# --- Proxy: Chats --- + +@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 --- diff --git a/src/app/(protected)/projects/[slug]/page.tsx b/src/app/(protected)/projects/[slug]/page.tsx index 99fe1f6..b3185a0 100644 --- a/src/app/(protected)/projects/[slug]/page.tsx +++ b/src/app/(protected)/projects/[slug]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import { getProjects, Project } from "@/lib/api"; import Sidebar from "@/components/Sidebar"; import KanbanBoard from "@/components/KanbanBoard"; +import ChatPanel from "@/components/ChatPanel"; export default function ProjectPage() { const { slug } = useParams<{ slug: string }>(); @@ -40,6 +41,7 @@ export default function ProjectPage() { )} +
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx new file mode 100644 index 0000000..cef9bdb --- /dev/null +++ b/src/components/ChatPanel.tsx @@ -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(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [expanded, setExpanded] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(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 ( +
+ +
+ ); + } + + const panelHeight = expanded ? "h-full" : "h-[30vh]"; + + return ( +
+ {/* Header */} +
+ 💬 Чат проекта +
+ + +
+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +

+ Пока пусто. Напишите первое сообщение 💬 +

+ )} + {messages.map((msg) => ( +
+ + {msg.sender_name || msg.sender_type}: + + {msg.content} + + {new Date(msg.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} + +
+ ))} +
+
+ + {/* Input */} +
+ 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} + /> + +
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 583cb75..cc609c8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -61,7 +61,7 @@ export interface Agent { id: string; name: string; slug: string; - adapter_id: string; + capabilities: string[]; subscription_mode: string; max_concurrent: number; status: string; @@ -93,5 +93,34 @@ export const deleteTask = (id: string) => // Agents export const getAgents = () => request("/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("/api/v1/chats/lobby"); +export const getProjectChat = (projectId: string) => + request(`/api/v1/chats/project/${projectId}`); +export const getChatMessages = (chatId: string, limit = 50) => + request(`/api/v1/chats/${chatId}/messages?limit=${limit}`); +export const sendChatMessage = (chatId: string, content: string) => + request(`/api/v1/chats/${chatId}/messages`, { + method: "POST", + body: JSON.stringify({ content }), + }); + // Labels export const getLabels = () => request("/api/v1/labels/"); diff --git a/src/lib/ws.ts b/src/lib/ws.ts new file mode 100644 index 0000000..cba2a25 --- /dev/null +++ b/src/lib/ws.ts @@ -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> = new Map(); + private reconnectTimer: ReturnType | null = null; + private subscribedRooms: Set = 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 = {}) { + 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();