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>
262 lines
9.3 KiB
TypeScript
262 lines
9.3 KiB
TypeScript
import { readBody } from "h3";
|
||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||
import { getAuthContext } from "../utils/auth";
|
||
import { prisma } from "../utils/prisma";
|
||
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 "";
|
||
return message.parts
|
||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||
.map((part: any) => part.text)
|
||
.join("")
|
||
.trim();
|
||
}
|
||
|
||
function getLastUserText(messages: any[]): string {
|
||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||
const message = messages[i];
|
||
if (message?.role !== "user") continue;
|
||
const text = extractMessageText(message);
|
||
if (text) return text;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
||
if (!raw || typeof raw !== "object") return null;
|
||
const item = raw as Record<string, any>;
|
||
const scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
|
||
const scopes = scopesRaw
|
||
.map((scope) => String(scope))
|
||
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
||
if (!scopes.length) return null;
|
||
|
||
const payload: PilotContextPayload = { scopes };
|
||
|
||
if (item.summary && typeof item.summary === "object") {
|
||
const contactId = String(item.summary.contactId ?? "").trim();
|
||
const name = String(item.summary.name ?? "").trim();
|
||
if (contactId && name) payload.summary = { contactId, name };
|
||
}
|
||
|
||
if (item.deal && typeof item.deal === "object") {
|
||
const dealId = String(item.deal.dealId ?? "").trim();
|
||
const title = String(item.deal.title ?? "").trim();
|
||
const contact = String(item.deal.contact ?? "").trim();
|
||
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
||
}
|
||
|
||
if (item.message && typeof item.message === "object") {
|
||
const contactId = String(item.message.contactId ?? "").trim();
|
||
const contact = String(item.message.contact ?? "").trim();
|
||
const intent = String(item.message.intent ?? "").trim();
|
||
if (intent === "add_message_or_reminder") {
|
||
payload.message = {
|
||
...(contactId ? { contactId } : {}),
|
||
...(contact ? { contact } : {}),
|
||
intent: "add_message_or_reminder",
|
||
};
|
||
}
|
||
}
|
||
|
||
if (item.calendar && typeof item.calendar === "object") {
|
||
const view = String(item.calendar.view ?? "").trim();
|
||
const period = String(item.calendar.period ?? "").trim();
|
||
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
|
||
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
|
||
const eventIds = Array.isArray(item.calendar.eventIds)
|
||
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
|
||
: [];
|
||
if (
|
||
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
||
period &&
|
||
selectedDateKey
|
||
) {
|
||
payload.calendar = {
|
||
view,
|
||
period,
|
||
selectedDateKey,
|
||
...(focusedEventId ? { focusedEventId } : {}),
|
||
eventIds,
|
||
};
|
||
}
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
function humanizeTraceText(trace: AgentTraceEvent): string {
|
||
if (trace.toolRun?.name) {
|
||
return `Использую инструмент: ${trace.toolRun.name}`;
|
||
}
|
||
|
||
const text = (trace.text ?? "").trim();
|
||
if (!text) return "Агент работает с данными CRM.";
|
||
|
||
if (text.toLowerCase().includes("ошиб")) return "Возникла ошибка шага, пробую другой путь.";
|
||
if (text.toLowerCase().includes("итог")) return "Готовлю финальный ответ.";
|
||
return text;
|
||
}
|
||
|
||
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
||
const totals = { created: 0, updated: 0, deleted: 0 };
|
||
for (const item of changeSet.items) {
|
||
if (item.action === "created") totals.created += 1;
|
||
else if (item.action === "updated") totals.updated += 1;
|
||
else if (item.action === "deleted") totals.deleted += 1;
|
||
}
|
||
|
||
const byEntity = new Map<string, number>();
|
||
for (const item of changeSet.items) {
|
||
byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1);
|
||
}
|
||
|
||
const lines = [
|
||
"Technical change summary",
|
||
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
|
||
...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`),
|
||
];
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
const auth = await getAuthContext(event);
|
||
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
|
||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||
const userText = getLastUserText(messages);
|
||
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
||
|
||
if (!userText) {
|
||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
||
}
|
||
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||
|
||
const stream = createUIMessageStream({
|
||
execute: async ({ writer }) => {
|
||
const textId = `text-${Date.now()}`;
|
||
writer.write({ type: "start" });
|
||
startPilotRun(auth.conversationId);
|
||
try {
|
||
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
||
|
||
await persistAiMessage({
|
||
teamId: auth.teamId,
|
||
conversationId: auth.conversationId,
|
||
authorUserId: auth.userId,
|
||
role: "USER",
|
||
text: userText,
|
||
requestId,
|
||
eventType: "user",
|
||
phase: "final",
|
||
transient: false,
|
||
});
|
||
|
||
const reply = await runCrmAgentFor({
|
||
teamId: auth.teamId,
|
||
userId: auth.userId,
|
||
userText,
|
||
contextPayload,
|
||
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: traceAt, text: traceText },
|
||
});
|
||
addPilotTrace(auth.conversationId, traceText);
|
||
broadcastToConversation(auth.conversationId, {
|
||
type: "pilot.trace",
|
||
text: traceText,
|
||
at: traceAt,
|
||
});
|
||
},
|
||
});
|
||
|
||
const snapshotAfter = await captureSnapshot(prisma, auth.teamId);
|
||
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
|
||
|
||
await persistAiMessage({
|
||
teamId: auth.teamId,
|
||
conversationId: auth.conversationId,
|
||
authorUserId: null,
|
||
role: "ASSISTANT",
|
||
text: reply.text,
|
||
requestId,
|
||
eventType: "assistant",
|
||
phase: "final",
|
||
transient: false,
|
||
});
|
||
|
||
if (changeSet) {
|
||
await persistAiMessage({
|
||
teamId: auth.teamId,
|
||
conversationId: auth.conversationId,
|
||
authorUserId: null,
|
||
role: "ASSISTANT",
|
||
text: renderChangeSetSummary(changeSet),
|
||
requestId,
|
||
eventType: "note",
|
||
phase: "final",
|
||
transient: false,
|
||
messageKind: "change_set_summary",
|
||
changeSet,
|
||
});
|
||
}
|
||
|
||
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 });
|
||
writer.write({ type: "finish", finishReason: "stop" });
|
||
} catch (error: any) {
|
||
const errorText = String(error?.message ?? error);
|
||
|
||
await persistAiMessage({
|
||
teamId: auth.teamId,
|
||
conversationId: auth.conversationId,
|
||
authorUserId: null,
|
||
role: "ASSISTANT",
|
||
text: errorText,
|
||
requestId,
|
||
eventType: "assistant",
|
||
phase: "error",
|
||
transient: false,
|
||
});
|
||
|
||
finishPilotRun(auth.conversationId, "error");
|
||
broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() });
|
||
|
||
writer.write({
|
||
type: "data-agent-log",
|
||
data: {
|
||
requestId,
|
||
at: new Date().toISOString(),
|
||
text: "Ошибка выполнения агентского цикла.",
|
||
},
|
||
});
|
||
writer.write({ type: "text-start", id: textId });
|
||
writer.write({
|
||
type: "text-delta",
|
||
id: textId,
|
||
delta: errorText,
|
||
});
|
||
writer.write({ type: "text-end", id: textId });
|
||
writer.write({ type: "finish", finishReason: "stop" });
|
||
}
|
||
},
|
||
});
|
||
|
||
return createUIMessageStreamResponse({ stream });
|
||
});
|