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:
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
59
frontend/server/utils/pilotRunStore.ts
Normal file
59
frontend/server/utils/pilotRunStore.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user