feat: frontend migration to Events API
This commit is contained in:
parent
2164c2e1c5
commit
bd6a4598d0
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Message, UploadedFile } from "@/lib/api";
|
import type { Event, UploadedFile } from "@/lib/api";
|
||||||
import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api";
|
import { getEvents, sendEvent, uploadFile, getAttachmentUrl } from "@/lib/api";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
import MentionInput from "./MentionInput";
|
import MentionInput from "./MentionInput";
|
||||||
|
|
||||||
@ -21,8 +21,7 @@ interface StreamingState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: string;
|
projectId: string;
|
||||||
projectId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@ -35,8 +34,8 @@ function isImageMime(mime: string | null): boolean {
|
|||||||
return !!mime && mime.startsWith("image/");
|
return !!mime && mime.startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ chatId, projectId }: Props) {
|
export default function ChatPanel({ projectId }: Props) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
|
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@ -50,34 +49,40 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isInitial = useRef(true);
|
const isInitial = useRef(true);
|
||||||
|
|
||||||
// Load initial messages
|
// Load initial events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!projectId) return;
|
||||||
isInitial.current = true;
|
isInitial.current = true;
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
getMessages({ chat_id: chatId, limit: PAGE_SIZE })
|
getEvents({
|
||||||
.then((msgs) => {
|
project_id: projectId,
|
||||||
setMessages(msgs);
|
types: "chat_message,task_created,task_status,task_assigned,task_unassigned",
|
||||||
setHasMore(msgs.length >= PAGE_SIZE);
|
limit: PAGE_SIZE
|
||||||
|
})
|
||||||
|
.then((evts) => {
|
||||||
|
setEvents(evts);
|
||||||
|
setHasMore(evts.length >= PAGE_SIZE);
|
||||||
setTimeout(() => bottomRef.current?.scrollIntoView(), 50);
|
setTimeout(() => bottomRef.current?.scrollIntoView(), 50);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [chatId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// WS: new messages
|
// WS: new events (message.new will now contain EventOut format)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = wsClient.on("message.new", (msg: Message) => {
|
const unsub = wsClient.on("message.new", (event: Event) => {
|
||||||
if (msg.chat_id !== chatId) return;
|
if (event.project_id !== projectId) return;
|
||||||
setMessages((prev) => {
|
// Filter out task_comment from chat display
|
||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (event.type === "task_comment") return;
|
||||||
return [...prev, msg];
|
setEvents((prev) => {
|
||||||
|
if (prev.some((e) => e.id === event.id)) return prev;
|
||||||
|
return [...prev, event];
|
||||||
});
|
});
|
||||||
// Clear streaming when final message arrives
|
// Clear streaming when final message arrives
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
setStreaming(null);
|
setStreaming(null);
|
||||||
});
|
});
|
||||||
return () => { unsub?.(); };
|
return () => { unsub?.(); };
|
||||||
}, [chatId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// WS: agent streaming events — use ref to accumulate, throttle renders
|
// WS: agent streaming events — use ref to accumulate, throttle renders
|
||||||
const streamRef = useRef<StreamingState | null>(null);
|
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) => {
|
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: [] };
|
streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
||||||
flushStream();
|
flushStream();
|
||||||
});
|
});
|
||||||
const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => {
|
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;
|
const s = streamRef.current;
|
||||||
if (!s) {
|
if (!s) {
|
||||||
streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
streamRef.current = { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
||||||
@ -115,7 +121,7 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
});
|
});
|
||||||
const unsubTool = wsClient.on("agent.stream.tool", (data: any) => {
|
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;
|
const s = streamRef.current;
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
const existing = s.tools.findIndex((t) => t.tool === data.tool);
|
const existing = s.tools.findIndex((t) => t.tool === data.tool);
|
||||||
@ -127,7 +133,7 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
scheduleFlush();
|
scheduleFlush();
|
||||||
});
|
});
|
||||||
const unsubEnd = wsClient.on("agent.stream.end", (data: any) => {
|
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
|
// Don't clear immediately — wait for message.new with final content
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@ -138,9 +144,9 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
if (streamRafRef.current) cancelAnimationFrame(streamRafRef.current);
|
if (streamRafRef.current) cancelAnimationFrame(streamRafRef.current);
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
};
|
};
|
||||||
}, [chatId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Auto-scroll on new messages (only if near bottom)
|
// Auto-scroll on new events (only if near bottom)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitial.current) {
|
if (isInitial.current) {
|
||||||
isInitial.current = false;
|
isInitial.current = false;
|
||||||
@ -151,32 +157,37 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [messages]);
|
}, [events]);
|
||||||
|
|
||||||
// Load older messages
|
// Load older events
|
||||||
const loadOlder = useCallback(async () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (loadingOlder || !hasMore || !chatId || messages.length === 0) return;
|
if (loadingOlder || !hasMore || !projectId || events.length === 0) return;
|
||||||
setLoadingOlder(true);
|
setLoadingOlder(true);
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
const prevHeight = el?.scrollHeight || 0;
|
const prevHeight = el?.scrollHeight || 0;
|
||||||
try {
|
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 < PAGE_SIZE) setHasMore(false);
|
||||||
if (older.length > 0) {
|
if (older.length > 0) {
|
||||||
setMessages((prev) => {
|
setEvents((prev) => {
|
||||||
const ids = new Set(prev.map((m) => m.id));
|
const ids = new Set(prev.map((e) => e.id));
|
||||||
return [...older.filter((m) => !ids.has(m.id)), ...prev];
|
return [...older.filter((e) => !ids.has(e.id)), ...prev];
|
||||||
});
|
});
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load older messages:", e);
|
console.error("Failed to load older events:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingOlder(false);
|
setLoadingOlder(false);
|
||||||
}
|
}
|
||||||
}, [chatId, messages.length, loadingOlder, hasMore]);
|
}, [projectId, events.length, loadingOlder, hasMore]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@ -207,7 +218,7 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
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 text = input.trim() || (pendingFiles.length > 0 ? `📎 ${pendingFiles.map(f => f.filename).join(", ")}` : "");
|
||||||
const attachments = pendingFiles.map((f) => ({
|
const attachments = pendingFiles.map((f) => ({
|
||||||
file_id: f.file_id,
|
file_id: f.file_id,
|
||||||
@ -228,15 +239,18 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
.map(slug => mentionMapRef.current.get(slug))
|
.map(slug => mentionMapRef.current.get(slug))
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
const msg = await sendMessage({
|
const event = await sendEvent({
|
||||||
chat_id: chatId,
|
type: "chat_message",
|
||||||
|
project_id: projectId,
|
||||||
|
payload: {
|
||||||
content: text,
|
content: text,
|
||||||
mentions: mentions.length > 0 ? mentions : undefined,
|
mentions: mentions.length > 0 ? mentions : undefined,
|
||||||
|
},
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
});
|
});
|
||||||
setMessages((prev) => {
|
setEvents((prev) => {
|
||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((e) => e.id === event.id)) return prev;
|
||||||
return [...prev, msg];
|
return [...prev, event];
|
||||||
});
|
});
|
||||||
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
||||||
} catch (e) {
|
} 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;
|
if (!attachments?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="ml-5 mt-1 flex flex-wrap gap-2">
|
<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 renderEvent = (event: Event) => {
|
||||||
const bgStyle =
|
// System events (task operations) vs chat messages
|
||||||
msg.author_type === "system"
|
const isSystemEvent = ["task_created", "task_status", "task_assigned", "task_unassigned"].includes(event.type);
|
||||||
? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
|
||||||
: msg.author_type === "agent"
|
if (isSystemEvent) {
|
||||||
? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
// System event rendering
|
||||||
: undefined;
|
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 (
|
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">
|
<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">
|
<span className="font-medium">
|
||||||
{msg.author_type === "system"
|
{event.actor ? `${event.actor.name} — Система` : "Система"}
|
||||||
? (msg.actor ? `${msg.actor.name} — Система` : "Система")
|
|
||||||
: (msg.author?.name || msg.author?.slug || "Unknown")}
|
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{(() => {
|
<span>{(() => {
|
||||||
const d = new Date(msg.created_at);
|
const d = new Date(event.created_at);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isToday = d.toDateString() === now.toDateString();
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
const time = d.toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
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}`;
|
return d.toLocaleString("ru-RU", { day: "numeric", month: "short" }) + `, ${time}`;
|
||||||
})()}</span>
|
})()}</span>
|
||||||
</div>
|
</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">
|
<details className="ml-5 mb-1">
|
||||||
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||||||
💭 Размышления
|
💭 Размышления
|
||||||
</summary>
|
</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">
|
<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>
|
</div>
|
||||||
</details>
|
</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">
|
<details className="ml-5 mb-1">
|
||||||
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||||||
🔧 Инструменты ({msg.tool_log.length})
|
🔧 Инструменты ({event.payload.tool_log.length})
|
||||||
</summary>
|
</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">
|
<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">
|
<div key={i} className="border-b border-white/10 pb-1 last:border-0">
|
||||||
<span className="font-mono text-blue-400">{t.name}</span>
|
<span className="font-mono text-blue-400">{t.name}</span>
|
||||||
{t.result && (
|
{t.result && (
|
||||||
@ -350,8 +407,8 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
<div className="ml-5 whitespace-pre-wrap">{renderContent(event.payload.content || "")}</div>
|
||||||
{renderAttachments(msg.attachments)}
|
{renderAttachments(event.attachments)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -366,11 +423,11 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
|||||||
{loadingOlder && (
|
{loadingOlder && (
|
||||||
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
|
<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>
|
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
||||||
)}
|
)}
|
||||||
{messages.map(renderMessage)}
|
{events.map(renderEvent)}
|
||||||
{messages.length === 0 && !streaming && (
|
{events.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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 CreateTaskModal from "@/components/CreateTaskModal";
|
||||||
import {
|
import {
|
||||||
updateTask, deleteTask, getMembers,
|
updateTask, deleteTask, getMembers,
|
||||||
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
|
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
|
||||||
getMessages, sendMessage,
|
getEvents, sendEvent,
|
||||||
getTaskLinks, createTaskLink, deleteTaskLink,
|
getTaskLinks, createTaskLink, deleteTaskLink,
|
||||||
searchTasks,
|
searchTasks,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@ -55,7 +55,7 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
|
|||||||
const [linkSearch, setLinkSearch] = useState("");
|
const [linkSearch, setLinkSearch] = useState("");
|
||||||
const [linkSearchResults, setLinkSearchResults] = useState<Task[]>([]);
|
const [linkSearchResults, setLinkSearchResults] = useState<Task[]>([]);
|
||||||
const [linkType, setLinkType] = useState("depends_on");
|
const [linkType, setLinkType] = useState("depends_on");
|
||||||
const [comments, setComments] = useState<Message[]>([]);
|
const [comments, setComments] = useState<Event[]>([]);
|
||||||
const [_saving, setSaving] = useState(false);
|
const [_saving, setSaving] = useState(false);
|
||||||
const [editingDesc, setEditingDesc] = useState(false);
|
const [editingDesc, setEditingDesc] = useState(false);
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
@ -68,7 +68,7 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
|
|||||||
getMembers().then(setMembers).catch(() => {});
|
getMembers().then(setMembers).catch(() => {});
|
||||||
getSteps(task.id).then(setSteps).catch(() => {});
|
getSteps(task.id).then(setSteps).catch(() => {});
|
||||||
getTaskLinks(task.id).then(setLinks).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]);
|
}, [task.id]);
|
||||||
|
|
||||||
const save = async (patch: Partial<Task>) => {
|
const save = async (patch: Partial<Task>) => {
|
||||||
@ -116,8 +116,12 @@ export default function TaskModal({ task, projectId: _projectId, projectSlug: _p
|
|||||||
const handleAddComment = async () => {
|
const handleAddComment = async () => {
|
||||||
if (!newComment.trim()) return;
|
if (!newComment.trim()) return;
|
||||||
try {
|
try {
|
||||||
const msg = await sendMessage({ task_id: task.id, content: newComment.trim() });
|
const event = await sendEvent({
|
||||||
setComments((prev) => [...prev, msg]);
|
type: "task_comment",
|
||||||
|
task_id: task.id,
|
||||||
|
payload: { content: newComment.trim() }
|
||||||
|
});
|
||||||
|
setComments((prev) => [...prev, event]);
|
||||||
setNewComment("");
|
setNewComment("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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>
|
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2">↵</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
||||||
{[...comments].reverse().map((msg) => (
|
{[...comments].reverse().map((event) => {
|
||||||
<div key={msg.id} className="text-sm">
|
// 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">
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
<span>{AUTHOR_ICON[authorType] || "👤"}</span>
|
||||||
<span className="font-medium">{msg.author_type === "system" ? "System" : (msg.author?.name || msg.author?.slug || "Unknown")}</span>
|
<span className="font-medium">{event.actor?.name || event.actor?.slug || "Unknown"}</span>
|
||||||
<span>·</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>
|
||||||
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
<div className="ml-5 whitespace-pre-wrap">{event.payload.content || ""}</div>
|
||||||
</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 && (
|
{comments.length === 0 && (
|
||||||
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
|
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -87,7 +87,6 @@ export interface Project {
|
|||||||
repo_urls: string[];
|
repo_urls: string[];
|
||||||
status: string;
|
status: string;
|
||||||
task_counter: number;
|
task_counter: number;
|
||||||
chat_id: string | null;
|
|
||||||
auto_assign: boolean;
|
auto_assign: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +151,32 @@ export interface Attachment {
|
|||||||
size: number;
|
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 {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
chat_id: string | null;
|
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" });
|
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[]> {
|
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise<Message[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|||||||
@ -100,13 +100,8 @@ export default function ProjectPage() {
|
|||||||
{activeTab === "board" && (
|
{activeTab === "board" && (
|
||||||
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "chat" && project.chat_id && (
|
{activeTab === "chat" && (
|
||||||
<ChatPanel chatId={project.chat_id} projectId={project.id} />
|
<ChatPanel projectId={project.id} />
|
||||||
)}
|
|
||||||
{activeTab === "chat" && !project.chat_id && (
|
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
|
||||||
Чат недоступен
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{activeTab === "files" && (
|
{activeTab === "files" && (
|
||||||
<ProjectFiles project={project} />
|
<ProjectFiles project={project} />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user