feat: frontend migration to Events API

This commit is contained in:
Markov 2026-03-18 20:11:00 +01:00
parent 2164c2e1c5
commit bd6a4598d0
4 changed files with 236 additions and 92 deletions

View File

@ -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<Message[]>([]);
export default function ChatPanel({ projectId }: Props) {
const [events, setEvents] = useState<Event[]>([]);
const [input, setInput] = useState("");
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
const [sending, setSending] = useState(false);
@ -50,34 +49,40 @@ export default function ChatPanel({ chatId, projectId }: Props) {
const fileInputRef = useRef<HTMLInputElement>(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<StreamingState | null>(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,
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 (
<div className="ml-5 mt-1 flex flex-wrap gap-2">
@ -293,25 +307,38 @@ 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 (
<div key={msg.id} className="text-sm" style={bgStyle}>
<div key={event.id} className="text-sm" style={{ backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
<span></span>
<span className="font-medium">
{msg.author_type === "system"
? (msg.actor ? `${msg.actor.name} — Система` : "Система")
: (msg.author?.name || msg.author?.slug || "Unknown")}
{event.actor ? `${event.actor.name} — Система` : "Система"}
</span>
<span>·</span>
<span>{(() => {
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 +348,53 @@ export default function ChatPanel({ chatId, projectId }: Props) {
return d.toLocaleString("ru-RU", { day: "numeric", month: "short" }) + `, ${time}`;
})()}</span>
</div>
{msg.thinking && (
<div className="ml-5">{systemText}</div>
</div>
);
}
// 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 (
<div key={event.id} className="text-sm" style={bgStyle}>
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span>{AUTHOR_ICON[authorType] || "👤"}</span>
<span className="font-medium">
{event.actor?.name || event.actor?.slug || "Unknown"}
</span>
<span>·</span>
<span>{(() => {
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}`;
})()}</span>
</div>
{event.payload.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}
{event.payload.thinking}
</div>
</details>
)}
{msg.tool_log && msg.tool_log.length > 0 && (
{event.payload.tool_log && event.payload.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})
🔧 Инструменты ({event.payload.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) => (
{event.payload.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 && (
@ -350,8 +407,8 @@ export default function ChatPanel({ chatId, projectId }: Props) {
</div>
</details>
)}
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
{renderAttachments(msg.attachments)}
<div className="ml-5 whitespace-pre-wrap">{renderContent(event.payload.content || "")}</div>
{renderAttachments(event.attachments)}
</div>
);
};
@ -366,11 +423,11 @@ export default function ChatPanel({ chatId, projectId }: Props) {
{loadingOlder && (
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
)}
{!hasMore && messages.length > 0 && (
{!hasMore && events.length > 0 && (
<div className="text-center text-xs text-[var(--muted)] py-2"> начало истории </div>
)}
{messages.map(renderMessage)}
{messages.length === 0 && !streaming && (
{events.map(renderEvent)}
{events.length === 0 && !streaming && (
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
)}

View File

@ -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<Task[]>([]);
const [linkType, setLinkType] = useState("depends_on");
const [comments, setComments] = useState<Message[]>([]);
const [comments, setComments] = useState<Event[]>([]);
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<Task>) => {
@ -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
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2"></button>
</div>
<div className="space-y-3 max-h-[300px] overflow-y-auto">
{[...comments].reverse().map((msg) => (
<div key={msg.id} className="text-sm">
{[...comments].reverse().map((event) => {
// Different rendering based on event type
if (event.type === "task_comment") {
const authorType = event.actor ? "agent" : "human";
return (
<div key={event.id} className="text-sm">
<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" ? "System" : (msg.author?.name || msg.author?.slug || "Unknown")}</span>
<span>{AUTHOR_ICON[authorType] || "👤"}</span>
<span className="font-medium">{event.actor?.name || event.actor?.slug || "Unknown"}</span>
<span>·</span>
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
<span>{new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
<div className="ml-5 whitespace-pre-wrap">{event.payload.content || ""}</div>
</div>
))}
);
}
// 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 (
<div key={event.id} className="text-sm">
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
<span></span>
<span className="font-medium">{event.actor ? `${event.actor.name} — Система` : "Система"}</span>
<span>·</span>
<span>{new Date(event.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<div className="ml-5">{systemText}</div>
</div>
);
})}
{comments.length === 0 && (
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
)}

View File

@ -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<void>
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<Event[]> {
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<Event> {
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<Message[]> {
const qs = new URLSearchParams();

View File

@ -100,13 +100,8 @@ export default function ProjectPage() {
{activeTab === "board" && (
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
)}
{activeTab === "chat" && project.chat_id && (
<ChatPanel chatId={project.chat_id} projectId={project.id} />
)}
{activeTab === "chat" && !project.chat_id && (
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
Чат недоступен
</div>
{activeTab === "chat" && (
<ChatPanel projectId={project.id} />
)}
{activeTab === "files" && (
<ProjectFiles project={project} />