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>
159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
import { ref } from "vue";
|
|
|
|
export type RealtimeNewMessage = {
|
|
contactId: string;
|
|
contactName: string;
|
|
text: string;
|
|
channel: string;
|
|
direction: string;
|
|
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;
|
|
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let crmRealtimeRefreshInFlight = false;
|
|
let crmRealtimeReconnectAttempt = 0;
|
|
|
|
function clearCrmRealtimeReconnectTimer() {
|
|
if (!crmRealtimeReconnectTimer) return;
|
|
clearTimeout(crmRealtimeReconnectTimer);
|
|
crmRealtimeReconnectTimer = null;
|
|
}
|
|
|
|
function clearCrmRealtimeRefreshTimer() {
|
|
if (!crmRealtimeRefreshTimer) return;
|
|
clearTimeout(crmRealtimeRefreshTimer);
|
|
crmRealtimeRefreshTimer = null;
|
|
}
|
|
|
|
async function runCrmRealtimeRefresh() {
|
|
if (!opts.isAuthenticated() || crmRealtimeRefreshInFlight) return;
|
|
crmRealtimeRefreshInFlight = true;
|
|
try {
|
|
await opts.onDashboardChanged();
|
|
} catch {
|
|
// ignore transient realtime refresh errors
|
|
} finally {
|
|
crmRealtimeRefreshInFlight = false;
|
|
}
|
|
}
|
|
|
|
function scheduleCrmRealtimeRefresh(delayMs = 250) {
|
|
clearCrmRealtimeRefreshTimer();
|
|
crmRealtimeRefreshTimer = setTimeout(() => {
|
|
crmRealtimeRefreshTimer = null;
|
|
void runCrmRealtimeRefresh();
|
|
}, delayMs);
|
|
}
|
|
|
|
function scheduleCrmRealtimeReconnect() {
|
|
clearCrmRealtimeReconnectTimer();
|
|
const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
|
|
crmRealtimeReconnectAttempt = attempt;
|
|
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
|
|
crmRealtimeReconnectTimer = setTimeout(() => {
|
|
crmRealtimeReconnectTimer = null;
|
|
startCrmRealtime();
|
|
}, delayMs);
|
|
}
|
|
|
|
function stopCrmRealtime() {
|
|
clearCrmRealtimeReconnectTimer();
|
|
clearCrmRealtimeRefreshTimer();
|
|
|
|
if (crmRealtimeSocket) {
|
|
const socket = crmRealtimeSocket;
|
|
crmRealtimeSocket = null;
|
|
socket.onopen = null;
|
|
socket.onmessage = null;
|
|
socket.onerror = null;
|
|
socket.onclose = null;
|
|
try {
|
|
socket.close(1000, "client stop");
|
|
} catch {
|
|
// ignore socket close errors
|
|
}
|
|
}
|
|
|
|
crmRealtimeState.value = "idle";
|
|
}
|
|
|
|
function startCrmRealtime() {
|
|
if (process.server || !opts.isAuthenticated()) return;
|
|
if (crmRealtimeSocket) {
|
|
const state = crmRealtimeSocket.readyState;
|
|
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
|
|
}
|
|
|
|
clearCrmRealtimeReconnectTimer();
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const url = `${protocol}//${window.location.host}/ws/crm-updates`;
|
|
|
|
const socket = new WebSocket(url);
|
|
crmRealtimeSocket = socket;
|
|
crmRealtimeState.value = "connecting";
|
|
|
|
socket.onopen = () => {
|
|
crmRealtimeState.value = "open";
|
|
crmRealtimeReconnectAttempt = 0;
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
const raw = typeof event.data === "string" ? event.data : "";
|
|
if (!raw) return;
|
|
try {
|
|
const payload = JSON.parse(raw) as { type?: string; [key: string]: any };
|
|
if (payload.type === "dashboard.changed") {
|
|
scheduleCrmRealtimeRefresh();
|
|
}
|
|
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
|
|
}
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
crmRealtimeState.value = "error";
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
const wasActive = crmRealtimeSocket === socket;
|
|
if (wasActive) {
|
|
crmRealtimeSocket = null;
|
|
}
|
|
if (!opts.isAuthenticated()) {
|
|
crmRealtimeState.value = "idle";
|
|
return;
|
|
}
|
|
crmRealtimeState.value = "error";
|
|
scheduleCrmRealtimeReconnect();
|
|
};
|
|
}
|
|
|
|
return { crmRealtimeState, startCrmRealtime, stopCrmRealtime };
|
|
}
|