From bd6a4598d041529145597eb2341a8adcf6e71bc5 Mon Sep 17 00:00:00 2001 From: Markov Date: Wed, 18 Mar 2026 20:11:00 +0100 Subject: [PATCH] feat: frontend migration to Events API --- src/components/ChatPanel.tsx | 191 +++++++++++++++++++++++------------ src/components/TaskModal.tsx | 73 ++++++++++--- src/lib/api.ts | 55 +++++++++- src/pages/ProjectPage.tsx | 9 +- 4 files changed, 236 insertions(+), 92 deletions(-) diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 75fa9e4..82d0384 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { Message, UploadedFile } from "@/lib/api"; -import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api"; +import type { Event, UploadedFile } from "@/lib/api"; +import { getEvents, sendEvent, uploadFile, getAttachmentUrl } from "@/lib/api"; import { wsClient } from "@/lib/ws"; import MentionInput from "./MentionInput"; @@ -21,8 +21,7 @@ interface StreamingState { } interface Props { - chatId: string; - projectId?: string; + projectId: string; } function formatFileSize(bytes: number): string { @@ -35,8 +34,8 @@ function isImageMime(mime: string | null): boolean { return !!mime && mime.startsWith("image/"); } -export default function ChatPanel({ chatId, projectId }: Props) { - const [messages, setMessages] = useState([]); +export default function ChatPanel({ projectId }: Props) { + const [events, setEvents] = useState([]); const [input, setInput] = useState(""); const mentionMapRef = useRef>(new Map()); // slug → member_id const [sending, setSending] = useState(false); @@ -50,34 +49,40 @@ export default function ChatPanel({ chatId, projectId }: Props) { const fileInputRef = useRef(null); const isInitial = useRef(true); - // Load initial messages + // Load initial events useEffect(() => { - if (!chatId) return; + if (!projectId) return; isInitial.current = true; setHasMore(true); - getMessages({ chat_id: chatId, limit: PAGE_SIZE }) - .then((msgs) => { - setMessages(msgs); - setHasMore(msgs.length >= PAGE_SIZE); + getEvents({ + project_id: projectId, + types: "chat_message,task_created,task_status,task_assigned,task_unassigned", + limit: PAGE_SIZE + }) + .then((evts) => { + setEvents(evts); + setHasMore(evts.length >= PAGE_SIZE); setTimeout(() => bottomRef.current?.scrollIntoView(), 50); }) .catch(() => {}); - }, [chatId]); + }, [projectId]); - // WS: new messages + // WS: new events (message.new will now contain EventOut format) 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]; + const unsub = wsClient.on("message.new", (event: Event) => { + if (event.project_id !== projectId) return; + // Filter out task_comment from chat display + if (event.type === "task_comment") return; + setEvents((prev) => { + if (prev.some((e) => e.id === event.id)) return prev; + return [...prev, event]; }); // Clear streaming when final message arrives streamRef.current = null; setStreaming(null); }); return () => { unsub?.(); }; - }, [chatId]); + }, [projectId]); // WS: agent streaming events — use ref to accumulate, throttle renders const streamRef = useRef(null); @@ -97,12 +102,13 @@ export default function ChatPanel({ chatId, projectId }: Props) { }; const unsubStart = wsClient.on("agent.stream.start", (data: any) => { - if (data.chat_id !== chatId) return; + // For project chat, check project_id; for task comments, check task_id + if (data.project_id !== projectId && !data.task_id) 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; + if (data.project_id !== projectId && !data.task_id) return; const s = streamRef.current; if (!s) { streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] }; @@ -115,7 +121,7 @@ export default function ChatPanel({ chatId, projectId }: Props) { scheduleFlush(); }); const unsubTool = wsClient.on("agent.stream.tool", (data: any) => { - if (data.chat_id !== chatId) return; + if (data.project_id !== projectId && !data.task_id) return; const s = streamRef.current; if (!s) return; const existing = s.tools.findIndex((t) => t.tool === data.tool); @@ -127,7 +133,7 @@ export default function ChatPanel({ chatId, projectId }: Props) { scheduleFlush(); }); const unsubEnd = wsClient.on("agent.stream.end", (data: any) => { - if (data.chat_id !== chatId) return; + if (data.project_id !== projectId && !data.task_id) return; // Don't clear immediately — wait for message.new with final content }); return () => { @@ -138,9 +144,9 @@ export default function ChatPanel({ chatId, projectId }: Props) { if (streamRafRef.current) cancelAnimationFrame(streamRafRef.current); streamRef.current = null; }; - }, [chatId]); + }, [projectId]); - // Auto-scroll on new messages (only if near bottom) + // Auto-scroll on new events (only if near bottom) useEffect(() => { if (isInitial.current) { isInitial.current = false; @@ -151,32 +157,37 @@ export default function ChatPanel({ chatId, projectId }: Props) { if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } - }, [messages]); + }, [events]); - // Load older messages + // Load older events const loadOlder = useCallback(async () => { - if (loadingOlder || !hasMore || !chatId || messages.length === 0) return; + if (loadingOlder || !hasMore || !projectId || events.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 }); + const older = await getEvents({ + project_id: projectId, + types: "chat_message,task_created,task_status,task_assigned,task_unassigned", + limit: PAGE_SIZE, + offset: events.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]; + setEvents((prev) => { + const ids = new Set(prev.map((e) => e.id)); + return [...older.filter((e) => !ids.has(e.id)), ...prev]; }); requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight; }); } } catch (e) { - console.error("Failed to load older messages:", e); + console.error("Failed to load older events:", e); } finally { setLoadingOlder(false); } - }, [chatId, messages.length, loadingOlder, hasMore]); + }, [projectId, events.length, loadingOlder, hasMore]); const handleScroll = useCallback(() => { const el = scrollRef.current; @@ -207,7 +218,7 @@ export default function ChatPanel({ chatId, projectId }: Props) { }; const handleSend = async () => { - if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return; + if ((!input.trim() && pendingFiles.length === 0) || !projectId || 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, @@ -228,15 +239,18 @@ export default function ChatPanel({ chatId, projectId }: Props) { .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, + const event = await sendEvent({ + type: "chat_message", + project_id: projectId, + payload: { + 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]; + setEvents((prev) => { + if (prev.some((e) => e.id === event.id)) return prev; + return [...prev, event]; }); setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50); } catch (e) { @@ -258,7 +272,7 @@ export default function ChatPanel({ chatId, projectId }: Props) { ); }; - const renderAttachments = (attachments: Message["attachments"]) => { + const renderAttachments = (attachments: Event["attachments"]) => { if (!attachments?.length) return null; return (
@@ -293,25 +307,68 @@ export default function ChatPanel({ chatId, projectId }: Props) { ); }; - 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; + const renderEvent = (event: Event) => { + // System events (task operations) vs chat messages + const isSystemEvent = ["task_created", "task_status", "task_assigned", "task_unassigned"].includes(event.type); + + if (isSystemEvent) { + // System event rendering + let systemText = ""; + switch (event.type) { + case "task_created": + systemText = `Задача создана: ${event.payload.title || "Без названия"}`; + break; + case "task_status": + systemText = `${event.payload.from} → ${event.payload.to}`; + break; + case "task_assigned": + systemText = `Назначена на @${event.payload.assignee}`; + break; + case "task_unassigned": + systemText = "Исполнитель снят"; + break; + } + + return ( +
+
+ ⚙️ + + {event.actor ? `${event.actor.name} — Система` : "Система"} + + · + {(() => { + const d = new Date(event.created_at); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + const time = d.toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + if (isToday) return time; + const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return `вчера, ${time}`; + return d.toLocaleString("ru-RU", { day: "numeric", month: "short" }) + `, ${time}`; + })()} +
+
{systemText}
+
+ ); + } + + // Chat message rendering + const authorType = event.actor ? "agent" : "human"; // rough approximation + const bgStyle = authorType === "agent" + ? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" } + : undefined; + return ( -
+
- {AUTHOR_ICON[msg.author_type] || "👤"} + {AUTHOR_ICON[authorType] || "👤"} - {msg.author_type === "system" - ? (msg.actor ? `${msg.actor.name} — Система` : "Система") - : (msg.author?.name || msg.author?.slug || "Unknown")} + {event.actor?.name || event.actor?.slug || "Unknown"} · {(() => { - const d = new Date(msg.created_at); + const d = new Date(event.created_at); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); const time = d.toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" }); @@ -321,23 +378,23 @@ export default function ChatPanel({ chatId, projectId }: Props) { return d.toLocaleString("ru-RU", { day: "numeric", month: "short" }) + `, ${time}`; })()}
- {msg.thinking && ( + {event.payload.thinking && (
💭 Размышления
- {msg.thinking} + {event.payload.thinking}
)} - {msg.tool_log && msg.tool_log.length > 0 && ( + {event.payload.tool_log && event.payload.tool_log.length > 0 && (
- 🔧 Инструменты ({msg.tool_log.length}) + 🔧 Инструменты ({event.payload.tool_log.length})
- {msg.tool_log.map((t, i) => ( + {event.payload.tool_log.map((t, i) => (
{t.name} {t.result && ( @@ -350,8 +407,8 @@ export default function ChatPanel({ chatId, projectId }: Props) {
)} -
{renderContent(msg.content)}
- {renderAttachments(msg.attachments)} +
{renderContent(event.payload.content || "")}
+ {renderAttachments(event.attachments)}
); }; @@ -366,11 +423,11 @@ export default function ChatPanel({ chatId, projectId }: Props) { {loadingOlder && (
Загрузка...
)} - {!hasMore && messages.length > 0 && ( + {!hasMore && events.length > 0 && (
— начало истории —
)} - {messages.map(renderMessage)} - {messages.length === 0 && !streaming && ( + {events.map(renderEvent)} + {events.length === 0 && !streaming && (
Нет сообщений
)} diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index d9485f3..1bb4327 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; -import type { Task, Member, Step, Message, TaskLink } from "@/lib/api"; +import type { Task, Member, Step, Event, TaskLink } from "@/lib/api"; import CreateTaskModal from "@/components/CreateTaskModal"; import { updateTask, deleteTask, getMembers, getSteps, createStep, updateStep, deleteStep as _deleteStepApi, - getMessages, sendMessage, + getEvents, sendEvent, getTaskLinks, createTaskLink, deleteTaskLink, searchTasks, } from "@/lib/api"; @@ -55,7 +55,7 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p const [linkSearch, setLinkSearch] = useState(""); const [linkSearchResults, setLinkSearchResults] = useState([]); const [linkType, setLinkType] = useState("depends_on"); - const [comments, setComments] = useState([]); + const [comments, setComments] = useState([]); const [_saving, setSaving] = useState(false); const [editingDesc, setEditingDesc] = useState(false); const [editingTitle, setEditingTitle] = useState(false); @@ -68,7 +68,7 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p getMembers().then(setMembers).catch(() => {}); getSteps(task.id).then(setSteps).catch(() => {}); getTaskLinks(task.id).then(setLinks).catch(() => {}); - getMessages({ task_id: task.id }).then(setComments).catch(() => {}); + getEvents({ task_id: task.id }).then(setComments).catch(() => {}); }, [task.id]); const save = async (patch: Partial) => { @@ -116,8 +116,12 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p const handleAddComment = async () => { if (!newComment.trim()) return; try { - const msg = await sendMessage({ task_id: task.id, content: newComment.trim() }); - setComments((prev) => [...prev, msg]); + const event = await sendEvent({ + type: "task_comment", + task_id: task.id, + payload: { content: newComment.trim() } + }); + setComments((prev) => [...prev, event]); setNewComment(""); } catch (e) { console.error(e); @@ -413,17 +417,54 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
- {[...comments].reverse().map((msg) => ( -
-
- {AUTHOR_ICON[msg.author_type] || "👤"} - {msg.author_type === "system" ? "System" : (msg.author?.name || msg.author?.slug || "Unknown")} - · - {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} + {[...comments].reverse().map((event) => { + // Different rendering based on event type + if (event.type === "task_comment") { + const authorType = event.actor ? "agent" : "human"; + return ( +
+
+ {AUTHOR_ICON[authorType] || "👤"} + {event.actor?.name || event.actor?.slug || "Unknown"} + · + {new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} +
+
{event.payload.content || ""}
+
+ ); + } + + // System events (task operations) + let systemText = ""; + switch (event.type) { + case "task_created": + systemText = `Задача создана: ${event.payload.title || "Без названия"}`; + break; + case "task_status": + systemText = `Статус изменён: ${event.payload.from} → ${event.payload.to}`; + break; + case "task_assigned": + systemText = `Назначена на @${event.payload.assignee}`; + break; + case "task_unassigned": + systemText = "Исполнитель снят"; + break; + default: + systemText = `Системное событие: ${event.type}`; + } + + return ( +
+
+ ⚙️ + {event.actor ? `${event.actor.name} — Система` : "Система"} + · + {new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })} +
+
{systemText}
-
{msg.content}
-
- ))} + ); + })} {comments.length === 0 && (
Нет комментариев
)} diff --git a/src/lib/api.ts b/src/lib/api.ts index ebf90b1..c7aa971 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -87,7 +87,6 @@ export interface Project { repo_urls: string[]; status: string; task_counter: number; - chat_id: string | null; auto_assign: boolean; } @@ -152,6 +151,32 @@ export interface Attachment { size: number; } +export interface Event { + id: string; + type: string; // chat_message, task_comment, task_created, task_status, task_assigned, task_unassigned + project_id: string; + task_id: string | null; + actor: MemberBrief | null; // или null для system без actor + payload: { + content?: string; + mentions?: string[]; + thinking?: string | null; + voice_url?: string | null; + tool_log?: Array<{name: string; args?: string; result?: string; error?: boolean}> | null; + // Task status changes + from?: string; + to?: string; + // Task assignment + assignee?: string; + // Task creation + title?: string; + status?: string; + }; + attachments: Attachment[]; + created_at: string; +} + +// Keep Message interface for backward compatibility during transition export interface Message { id: string; chat_id: string | null; @@ -276,7 +301,33 @@ export async function deleteStep(taskId: string, stepId: string): Promise await request(`/api/v1/tasks/${taskId}/steps/${stepId}`, { method: "DELETE" }); } -// --- Messages (unified: chat + task comments) --- +// --- Events API --- + +export async function getEvents(params: { project_id?: string; task_id?: string; types?: string; limit?: number; offset?: number }): Promise { + const qs = new URLSearchParams(); + if (params.project_id) qs.set("project_id", params.project_id); + if (params.task_id) qs.set("task_id", params.task_id); + if (params.types) qs.set("types", params.types); + if (params.limit) qs.set("limit", String(params.limit)); + if (params.offset) qs.set("offset", String(params.offset)); + return request(`/api/v1/events?${qs}`); +} + +export async function sendEvent(data: { + type: string; + project_id?: string; + task_id?: string; + payload: { + content?: string; + mentions?: string[]; + [key: string]: any; + }; + attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[]; +}): Promise { + return request("/api/v1/events", { method: "POST", body: JSON.stringify(data) }); +} + +// --- Messages (unified: chat + task comments) --- [DEPRECATED - use Events API] export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise { const qs = new URLSearchParams(); diff --git a/src/pages/ProjectPage.tsx b/src/pages/ProjectPage.tsx index c5f017f..1cb714a 100644 --- a/src/pages/ProjectPage.tsx +++ b/src/pages/ProjectPage.tsx @@ -100,13 +100,8 @@ export default function ProjectPage() { {activeTab === "board" && ( )} - {activeTab === "chat" && project.chat_id && ( - - )} - {activeTab === "chat" && !project.chat_id && ( -
- Чат недоступен -
+ {activeTab === "chat" && ( + )} {activeTab === "files" && (