From 0df426d5d67d0853b3dba23d1372ff108dc3c287 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 8 Mar 2026 19:04:04 +0700 Subject: [PATCH] refactor pilot chat api contract and typed ai-sdk flow --- frontend/app/composables/usePilotChat.ts | 53 +++++--- frontend/server/api/pilot-chat.post.ts | 160 +++++++++++++++-------- 2 files changed, 138 insertions(+), 75 deletions(-) diff --git a/frontend/app/composables/usePilotChat.ts b/frontend/app/composables/usePilotChat.ts index a9f85e5..91dc96d 100644 --- a/frontend/app/composables/usePilotChat.ts +++ b/frontend/app/composables/usePilotChat.ts @@ -10,7 +10,7 @@ import { MeQueryDocument, } from "~~/graphql/generated"; import { Chat as AiChat } from "@ai-sdk/vue"; -import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai"; +import { DefaultChatTransport, isTextUIPart, type DataUIPart, type UIMessage } from "ai"; import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription"; import type { Contact } from "~/composables/useContacts"; @@ -90,8 +90,31 @@ export type ChatConversation = { lastMessageText?: string | null; }; +type PilotDataTypes = { + "agent-log": { + requestId: string; + at: string; + text: string; + }; +}; + +type PilotUiMessage = UIMessage; + function safeTrim(value: unknown) { return String(value ?? "").trim(); } +function parsePilotAgentLog(part: DataUIPart) { + if (part.type !== "data-agent-log") return null; + + const data = part.data as Partial | null | undefined; + const text = safeTrim(data?.text); + if (!text) return null; + + return { + text, + at: safeTrim(data?.at) || new Date().toISOString(), + }; +} + export function usePilotChat(opts: { apolloAuthReady: ComputedRef; authMe: Ref; @@ -191,24 +214,24 @@ export function usePilotChat(opts: { // --------------------------------------------------------------------------- // AI SDK chat instance // --------------------------------------------------------------------------- - const pilotChat = new AiChat({ + const pilotChat = new AiChat({ transport: new DefaultChatTransport({ api: "/api/pilot-chat", }), - onData: (part: any) => { - if (part?.type !== "data-agent-log") return; - const text = String(part?.data?.text ?? "").trim(); - if (!text) return; - const at = String(part?.data?.at ?? new Date().toISOString()); - pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text, at }]; + onData: (part) => { + const log = parsePilotAgentLog(part); + if (!log) return; + pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text: log.text, at: log.at }]; }, onFinish: async () => { + pilotSending.value = false; livePilotUserText.value = ""; livePilotAssistantText.value = ""; pilotLiveLogs.value = []; await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]); }, onError: () => { + pilotSending.value = false; if (livePilotUserText.value) { pilotInput.value = livePilotUserText.value; } @@ -285,7 +308,7 @@ export function usePilotChat(opts: { // --------------------------------------------------------------------------- // Pilot ↔ UIMessage bridge // --------------------------------------------------------------------------- - function pilotToUiMessage(message: PilotMessage): UIMessage { + function pilotToUiMessage(message: PilotMessage): PilotUiMessage { return { id: message.id, role: message.role, @@ -377,20 +400,8 @@ export function usePilotChat(opts: { ); } catch { pilotInput.value = text; - } finally { - const latestAssistant = [...pilotChat.messages] - .reverse() - .find((message) => message.role === "assistant"); - - if (latestAssistant) { - const textPart = latestAssistant.parts.find(isTextUIPart); - livePilotAssistantText.value = textPart?.text ?? ""; - } - livePilotUserText.value = ""; - livePilotAssistantText.value = ""; pilotSending.value = false; - await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]); } } diff --git a/frontend/server/api/pilot-chat.post.ts b/frontend/server/api/pilot-chat.post.ts index d7a5ad5..c16d7a4 100644 --- a/frontend/server/api/pilot-chat.post.ts +++ b/frontend/server/api/pilot-chat.post.ts @@ -1,5 +1,11 @@ import { readBody } from "h3"; -import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { + createUIMessageStream, + createUIMessageStreamResponse, + type UIMessage, + type UIMessageStreamWriter, + validateUIMessages, +} from "ai"; import { getAuthContext } from "../utils/auth"; import { prisma } from "../utils/prisma"; import { buildChangeSet, captureSnapshot } from "../utils/changeSet"; @@ -9,53 +15,74 @@ 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 ""; +type PilotDataTypes = { + "agent-log": { + requestId: string; + at: string; + text: string; + }; +}; + +type PilotUiMessage = UIMessage; + +type PilotChatRequestBody = { + messages?: unknown; + contextPayload?: unknown; +}; + +function extractMessageText(message: PilotUiMessage): string { return message.parts - .filter((part: any) => part?.type === "text" && typeof part.text === "string") - .map((part: any) => part.text) + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) .join("") .trim(); } -function getLastUserText(messages: any[]): string { +function getLastUserText(messages: PilotUiMessage[]): string { for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; - if (message?.role !== "user") continue; + 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; + + const item = raw as Record; 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(); + const summary = item.summary as Record; + const contactId = String(summary.contactId ?? "").trim(); + const name = String(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(); + const deal = item.deal as Record; + const dealId = String(deal.dealId ?? "").trim(); + const title = String(deal.title ?? "").trim(); + const contact = String(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(); + const message = item.message as Record; + const contactId = String(message.contactId ?? "").trim(); + const contact = String(message.contact ?? "").trim(); + const intent = String(message.intent ?? "").trim(); + if (intent === "add_message_or_reminder") { payload.message = { ...(contactId ? { contactId } : {}), @@ -66,13 +93,15 @@ function sanitizeContextPayload(raw: unknown): PilotContextPayload | null { } 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) + const calendar = item.calendar as Record; + const view = String(calendar.view ?? "").trim(); + const period = String(calendar.period ?? "").trim(); + const selectedDateKey = String(calendar.selectedDateKey ?? "").trim(); + const focusedEventId = String(calendar.focusedEventId ?? "").trim(); + const eventIds = Array.isArray(calendar.eventIds) + ? calendar.eventIds.map((id) => String(id ?? "").trim()).filter(Boolean) : []; + if ( (view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") && period && @@ -106,6 +135,7 @@ function humanizeTraceText(trace: AgentTraceEvent): string { 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; @@ -126,23 +156,54 @@ function renderChangeSetSummary(changeSet: ChangeSet): string { return lines.join("\n"); } +function writePilotLog(writer: UIMessageStreamWriter, payload: PilotDataTypes["agent-log"]) { + writer.write({ + type: "data-agent-log", + data: payload, + }); +} + +function writeAssistantText(writer: UIMessageStreamWriter, textId: string, text: string) { + writer.write({ type: "text-start", id: textId }); + writer.write({ type: "text-delta", id: textId, delta: text }); + writer.write({ type: "text-end", id: textId }); +} + +function finalizePilotExecution(conversationId: string, status: "finished" | "error") { + finishPilotRun(conversationId, status); + broadcastToConversation(conversationId, { + type: "pilot.finished", + at: new Date().toISOString(), + }); +} + 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 body = await readBody(event); + + let messages: PilotUiMessage[] = []; + try { + messages = await validateUIMessages({ messages: body?.messages ?? [] }); + } catch { + throw createError({ statusCode: 400, statusMessage: "Invalid chat messages payload" }); + } + 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({ + 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); @@ -168,10 +229,13 @@ export default defineEventHandler(async (event) => { 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 }, + + writePilotLog(writer, { + requestId, + at: traceAt, + text: traceText, }); + addPilotTrace(auth.conversationId, traceText); broadcastToConversation(auth.conversationId, { type: "pilot.trace", @@ -212,15 +276,12 @@ export default defineEventHandler(async (event) => { }); } - finishPilotRun(auth.conversationId, "finished"); - broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() }); + finalizePilotExecution(auth.conversationId, "finished"); - writer.write({ type: "text-start", id: textId }); - writer.write({ type: "text-delta", id: textId, delta: reply.text }); - writer.write({ type: "text-end", id: textId }); + writeAssistantText(writer, textId, reply.text); writer.write({ type: "finish", finishReason: "stop" }); - } catch (error: any) { - const errorText = String(error?.message ?? error); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); await persistAiMessage({ teamId: auth.teamId, @@ -232,27 +293,18 @@ export default defineEventHandler(async (event) => { eventType: "assistant", phase: "error", transient: false, + }).catch(() => undefined); + + finalizePilotExecution(auth.conversationId, "error"); + + writePilotLog(writer, { + requestId, + at: new Date().toISOString(), + text: "Ошибка выполнения агентского цикла.", }); - 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" }); + writeAssistantText(writer, textId, errorText); + writer.write({ type: "finish", finishReason: "error" }); } }, });