refactor pilot chat api contract and typed ai-sdk flow
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user