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 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,
|
||||
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 (
|
||||
<div className="ml-5 mt-1 flex flex-wrap gap-2">
|
||||
@ -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 (
|
||||
<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>⚙️</span>
|
||||
<span className="font-medium">
|
||||
{event.actor ? `${event.actor.name} — Система` : "Система"}
|
||||
</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>
|
||||
<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={msg.id} className="text-sm" style={bgStyle}>
|
||||
<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[msg.author_type] || "👤"}</span>
|
||||
<span>{AUTHOR_ICON[authorType] || "👤"}</span>
|
||||
<span className="font-medium">
|
||||
{msg.author_type === "system"
|
||||
? (msg.actor ? `${msg.actor.name} — Система` : "Система")
|
||||
: (msg.author?.name || msg.author?.slug || "Unknown")}
|
||||
{event.actor?.name || event.actor?.slug || "Unknown"}
|
||||
</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 +378,23 @@ export default function ChatPanel({ chatId, projectId }: Props) {
|
||||
return d.toLocaleString("ru-RU", { day: "numeric", month: "short" }) + `, ${time}`;
|
||||
})()}</span>
|
||||
</div>
|
||||
{msg.thinking && (
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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">
|
||||
<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>·</span>
|
||||
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
{[...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[authorType] || "👤"}</span>
|
||||
<span className="font-medium">{event.actor?.name || event.actor?.slug || "Unknown"}</span>
|
||||
<span>·</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">{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>
|
||||
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{comments.length === 0 && (
|
||||
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
|
||||
)}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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} />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user