refactor pilot chat api contract and typed ai-sdk flow

This commit is contained in:
Ruslan Bakiev
2026-03-08 19:04:04 +07:00
parent 7d1bed0d67
commit 0df426d5d6
2 changed files with 138 additions and 75 deletions

View File

@@ -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<unknown, PilotDataTypes>;
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
function parsePilotAgentLog(part: DataUIPart<PilotDataTypes>) {
if (part.type !== "data-agent-log") return null;
const data = part.data as Partial<PilotDataTypes["agent-log"]> | 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<boolean>;
authMe: Ref<any>;
@@ -191,24 +214,24 @@ export function usePilotChat(opts: {
// ---------------------------------------------------------------------------
// AI SDK chat instance
// ---------------------------------------------------------------------------
const pilotChat = new AiChat<UIMessage>({
const pilotChat = new AiChat<PilotUiMessage>({
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()]);
}
}

View File

@@ -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<unknown, PilotDataTypes>;
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<PilotUiMessage["parts"][number], { type: "text" }> => 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<string, any>;
const item = raw as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<PilotUiMessage>, payload: PilotDataTypes["agent-log"]) {
writer.write({
type: "data-agent-log",
data: payload,
});
}
function writeAssistantText(writer: UIMessageStreamWriter<PilotUiMessage>, 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<PilotChatRequestBody>(event);
let messages: PilotUiMessage[] = [];
try {
messages = await validateUIMessages<PilotUiMessage>({ 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<PilotUiMessage>({
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" });
}
},
});