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

View File

@@ -9,10 +9,14 @@ export type RealtimeNewMessage = {
at: string;
};
export type RealtimePilotTrace = { text: string; at: string };
export function useCrmRealtime(opts: {
isAuthenticated: () => boolean;
onDashboardChanged: () => Promise<void>;
onNewMessage?: (msg: RealtimeNewMessage) => void;
onPilotTrace?: (log: RealtimePilotTrace) => void;
onPilotFinished?: () => void;
}) {
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
@@ -116,6 +120,17 @@ export function useCrmRealtime(opts: {
if (payload.type === "message.new" && opts.onNewMessage) {
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 {
// ignore malformed realtime payloads
}

View File

@@ -689,6 +689,24 @@ export function usePilotChat(opts: {
pushPilotNote,
refetchChatMessages,
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
destroyPilotWaveSurfer,
};

View File

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

View File

@@ -1,4 +1,5 @@
import { prisma } from "../../utils/prisma";
import { getActivePilotRun } from "../../utils/pilotRunStore";
function mapChannel(channel: string) {
if (channel === "TELEGRAM") return "Telegram";
@@ -19,6 +20,10 @@ const peerTeamById = new Map<string, string>();
const lastSignatureByTeam = new Map<string, string>();
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;
function parseCookies(raw: string | null) {
@@ -42,20 +47,42 @@ function attachPeerToTeam(peer: any, teamId: string) {
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) {
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);
if (!peers) return;
if (peers) {
peers.delete(peer);
if (peers.size === 0) {
peersByTeam.delete(teamId);
lastSignatureByTeam.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() {
@@ -83,7 +110,7 @@ async function validateSessionFromPeer(peer: any) {
]);
if (!user || !team || !conv) return null;
return { teamId };
return { teamId, conversationId };
}
async function computeTeamSignature(teamId: string) {
@@ -209,6 +236,17 @@ function ensurePoll() {
}, 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({
async open(peer) {
const session = await validateSessionFromPeer(peer);
@@ -218,9 +256,20 @@ export default defineWebSocketHandler({
}
attachPeerToTeam(peer, session.teamId);
attachPeerToConversation(peer, session.conversationId);
ensurePoll();
sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() });
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) {

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;
}