feat: broadcast pilot agent traces via WebSocket for live status on reconnect

Agent trace logs are now stored in-memory (pilotRunStore) and broadcast
through the existing /ws/crm-updates WebSocket channel. When a client
reconnects, it receives a pilot.catchup with all accumulated logs so the
user sees agent progress even after page reload. Three new WS event
types: pilot.trace, pilot.finished, pilot.catchup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-25 08:45:32 +07:00
parent b830f3728c
commit bf7f4ae933
6 changed files with 174 additions and 16 deletions

View File

@@ -373,6 +373,8 @@ const {
pushPilotNote, pushPilotNote,
refetchChatMessages, refetchChatMessages,
refetchChatConversations, refetchChatConversations,
handleRealtimePilotTrace,
handleRealtimePilotFinished,
destroyPilotWaveSurfer, destroyPilotWaveSurfer,
togglePilotLiveLogsExpanded, togglePilotLiveLogsExpanded,
} = pilotChat; } = pilotChat;
@@ -678,6 +680,8 @@ const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({
// Refresh contacts to update sidebar preview (lastMessageText, lastAt, hasUnread) // Refresh contacts to update sidebar preview (lastMessageText, lastAt, hasUnread)
void refetchContacts(); void refetchContacts();
}, },
onPilotTrace: (log) => handleRealtimePilotTrace(log),
onPilotFinished: () => void handleRealtimePilotFinished(),
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -9,10 +9,14 @@ export type RealtimeNewMessage = {
at: string; at: string;
}; };
export type RealtimePilotTrace = { text: string; at: string };
export function useCrmRealtime(opts: { export function useCrmRealtime(opts: {
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
onDashboardChanged: () => Promise<void>; onDashboardChanged: () => Promise<void>;
onNewMessage?: (msg: RealtimeNewMessage) => void; onNewMessage?: (msg: RealtimeNewMessage) => void;
onPilotTrace?: (log: RealtimePilotTrace) => void;
onPilotFinished?: () => void;
}) { }) {
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle"); const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null; let crmRealtimeSocket: WebSocket | null = null;
@@ -116,6 +120,17 @@ export function useCrmRealtime(opts: {
if (payload.type === "message.new" && opts.onNewMessage) { if (payload.type === "message.new" && opts.onNewMessage) {
opts.onNewMessage(payload as unknown as RealtimeNewMessage); opts.onNewMessage(payload as unknown as RealtimeNewMessage);
} }
if (payload.type === "pilot.trace" && opts.onPilotTrace) {
opts.onPilotTrace({ text: String(payload.text ?? ""), at: String(payload.at ?? "") });
}
if (payload.type === "pilot.catchup" && opts.onPilotTrace && Array.isArray(payload.logs)) {
for (const log of payload.logs) {
opts.onPilotTrace({ text: String((log as any).text ?? ""), at: String((log as any).at ?? "") });
}
}
if (payload.type === "pilot.finished" && opts.onPilotFinished) {
opts.onPilotFinished();
}
} catch { } catch {
// ignore malformed realtime payloads // ignore malformed realtime payloads
} }

View File

@@ -689,6 +689,24 @@ export function usePilotChat(opts: {
pushPilotNote, pushPilotNote,
refetchChatMessages, refetchChatMessages,
refetchChatConversations, refetchChatConversations,
// realtime pilot trace handlers (called from useCrmRealtime)
handleRealtimePilotTrace(log: { text: string; at: string }) {
const text = String(log.text ?? "").trim();
if (!text) return;
// Mark as sending so the UI shows live-log panel
if (!pilotSending.value) pilotSending.value = true;
pilotLiveLogs.value = [
...pilotLiveLogs.value,
{ id: `ws-${Date.now()}-${Math.random()}`, text, at: log.at || new Date().toISOString() },
];
},
async handleRealtimePilotFinished() {
pilotSending.value = false;
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
},
// cleanup // cleanup
destroyPilotWaveSurfer, destroyPilotWaveSurfer,
}; };

View File

@@ -6,6 +6,8 @@ import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
import { persistAiMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent"; import { persistAiMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
import type { PilotContextPayload } from "../agent/crmAgent"; import type { PilotContextPayload } from "../agent/crmAgent";
import type { ChangeSet } from "../utils/changeSet"; import type { ChangeSet } from "../utils/changeSet";
import { startPilotRun, addPilotTrace, finishPilotRun } from "../utils/pilotRunStore";
import { broadcastToConversation } from "../routes/ws/crm-updates";
function extractMessageText(message: any): string { function extractMessageText(message: any): string {
if (!message || !Array.isArray(message.parts)) return ""; if (!message || !Array.isArray(message.parts)) return "";
@@ -140,6 +142,7 @@ export default defineEventHandler(async (event) => {
execute: async ({ writer }) => { execute: async ({ writer }) => {
const textId = `text-${Date.now()}`; const textId = `text-${Date.now()}`;
writer.write({ type: "start" }); writer.write({ type: "start" });
startPilotRun(auth.conversationId);
try { try {
const snapshotBefore = await captureSnapshot(prisma, auth.teamId); const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
@@ -163,13 +166,17 @@ export default defineEventHandler(async (event) => {
requestId, requestId,
conversationId: auth.conversationId, conversationId: auth.conversationId,
onTrace: async (trace: AgentTraceEvent) => { onTrace: async (trace: AgentTraceEvent) => {
const traceText = humanizeTraceText(trace);
const traceAt = new Date().toISOString();
writer.write({ writer.write({
type: "data-agent-log", type: "data-agent-log",
data: { data: { requestId, at: traceAt, text: traceText },
requestId, });
at: new Date().toISOString(), addPilotTrace(auth.conversationId, traceText);
text: humanizeTraceText(trace), broadcastToConversation(auth.conversationId, {
}, type: "pilot.trace",
text: traceText,
at: traceAt,
}); });
}, },
}); });
@@ -205,6 +212,9 @@ export default defineEventHandler(async (event) => {
}); });
} }
finishPilotRun(auth.conversationId, "finished");
broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() });
writer.write({ type: "text-start", id: textId }); writer.write({ type: "text-start", id: textId });
writer.write({ type: "text-delta", id: textId, delta: reply.text }); writer.write({ type: "text-delta", id: textId, delta: reply.text });
writer.write({ type: "text-end", id: textId }); writer.write({ type: "text-end", id: textId });
@@ -224,6 +234,9 @@ export default defineEventHandler(async (event) => {
transient: false, transient: false,
}); });
finishPilotRun(auth.conversationId, "error");
broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() });
writer.write({ writer.write({
type: "data-agent-log", type: "data-agent-log",
data: { data: {

View File

@@ -1,4 +1,5 @@
import { prisma } from "../../utils/prisma"; import { prisma } from "../../utils/prisma";
import { getActivePilotRun } from "../../utils/pilotRunStore";
function mapChannel(channel: string) { function mapChannel(channel: string) {
if (channel === "TELEGRAM") return "Telegram"; if (channel === "TELEGRAM") return "Telegram";
@@ -19,6 +20,10 @@ const peerTeamById = new Map<string, string>();
const lastSignatureByTeam = new Map<string, string>(); const lastSignatureByTeam = new Map<string, string>();
const lastMsgCreatedAtByTeam = new Map<string, Date>(); const lastMsgCreatedAtByTeam = new Map<string, Date>();
// Conversation-level tracking for pilot events
const peersByConversation = new Map<string, Set<any>>();
const peerConversationById = new Map<string, string>();
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
function parseCookies(raw: string | null) { function parseCookies(raw: string | null) {
@@ -42,20 +47,42 @@ function attachPeerToTeam(peer: any, teamId: string) {
peerTeamById.set(String(peer.id), teamId); peerTeamById.set(String(peer.id), teamId);
} }
function attachPeerToConversation(peer: any, conversationId: string) {
if (!peersByConversation.has(conversationId)) peersByConversation.set(conversationId, new Set());
peersByConversation.get(conversationId)?.add(peer);
peerConversationById.set(String(peer.id), conversationId);
}
function detachPeer(peer: any) { function detachPeer(peer: any) {
const key = String(peer.id); const key = String(peer.id);
const teamId = peerTeamById.get(key);
if (!teamId) return;
peerTeamById.delete(key);
// Detach from team
const teamId = peerTeamById.get(key);
if (teamId) {
peerTeamById.delete(key);
const peers = peersByTeam.get(teamId); const peers = peersByTeam.get(teamId);
if (!peers) return; if (peers) {
peers.delete(peer); peers.delete(peer);
if (peers.size === 0) { if (peers.size === 0) {
peersByTeam.delete(teamId); peersByTeam.delete(teamId);
lastSignatureByTeam.delete(teamId); lastSignatureByTeam.delete(teamId);
lastMsgCreatedAtByTeam.delete(teamId); lastMsgCreatedAtByTeam.delete(teamId);
} }
}
}
// Detach from conversation
const convId = peerConversationById.get(key);
if (convId) {
peerConversationById.delete(key);
const convPeers = peersByConversation.get(convId);
if (convPeers) {
convPeers.delete(peer);
if (convPeers.size === 0) {
peersByConversation.delete(convId);
}
}
}
} }
function stopPollIfIdle() { function stopPollIfIdle() {
@@ -83,7 +110,7 @@ async function validateSessionFromPeer(peer: any) {
]); ]);
if (!user || !team || !conv) return null; if (!user || !team || !conv) return null;
return { teamId }; return { teamId, conversationId };
} }
async function computeTeamSignature(teamId: string) { async function computeTeamSignature(teamId: string) {
@@ -209,6 +236,17 @@ function ensurePoll() {
}, TEAM_POLL_INTERVAL_MS); }, TEAM_POLL_INTERVAL_MS);
} }
// ---------------------------------------------------------------------------
// Public: broadcast to all peers connected for a given conversation
// ---------------------------------------------------------------------------
export function broadcastToConversation(conversationId: string, payload: Record<string, unknown>) {
const peers = peersByConversation.get(conversationId);
if (!peers) return;
for (const peer of peers) {
sendJson(peer, payload);
}
}
export default defineWebSocketHandler({ export default defineWebSocketHandler({
async open(peer) { async open(peer) {
const session = await validateSessionFromPeer(peer); const session = await validateSessionFromPeer(peer);
@@ -218,9 +256,20 @@ export default defineWebSocketHandler({
} }
attachPeerToTeam(peer, session.teamId); attachPeerToTeam(peer, session.teamId);
attachPeerToConversation(peer, session.conversationId);
ensurePoll(); ensurePoll();
sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() }); sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() });
void pollAndBroadcast(); void pollAndBroadcast();
// Send catch-up for active pilot run
const activeRun = getActivePilotRun(session.conversationId);
if (activeRun) {
sendJson(peer, {
type: "pilot.catchup",
logs: activeRun.logs,
at: new Date().toISOString(),
});
}
}, },
close(peer) { close(peer) {

View File

@@ -0,0 +1,59 @@
export type PilotRunLog = { id: string; text: string; at: string };
export type PilotRun = {
status: "running" | "finished" | "error";
logs: PilotRunLog[];
startedAt: string;
finishedAt?: string;
};
const activeRuns = new Map<string, PilotRun>();
const MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
function cleanup() {
const now = Date.now();
for (const [key, run] of activeRuns) {
if (now - new Date(run.startedAt).getTime() > MAX_AGE_MS) {
activeRuns.delete(key);
}
}
}
export function startPilotRun(conversationId: string) {
cleanup();
activeRuns.set(conversationId, {
status: "running",
logs: [],
startedAt: new Date().toISOString(),
});
}
export function addPilotTrace(conversationId: string, text: string) {
const run = activeRuns.get(conversationId);
if (!run || run.status !== "running") return;
run.logs.push({
id: `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`,
text,
at: new Date().toISOString(),
});
}
export function finishPilotRun(conversationId: string, status: "finished" | "error" = "finished") {
const run = activeRuns.get(conversationId);
if (!run) return;
run.status = status;
run.finishedAt = new Date().toISOString();
// Keep for a short time so late-connecting clients can see it finished
setTimeout(() => {
if (activeRuns.get(conversationId) === run) {
activeRuns.delete(conversationId);
}
}, 5000);
}
export function getActivePilotRun(conversationId: string): PilotRun | null {
const run = activeRuns.get(conversationId);
if (!run || run.status !== "running") return null;
return run;
}