refactor pilot chat api contract and typed ai-sdk flow
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
|||||||
MeQueryDocument,
|
MeQueryDocument,
|
||||||
} from "~~/graphql/generated";
|
} from "~~/graphql/generated";
|
||||||
import { Chat as AiChat } from "@ai-sdk/vue";
|
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 { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
|
||||||
|
|
||||||
import type { Contact } from "~/composables/useContacts";
|
import type { Contact } from "~/composables/useContacts";
|
||||||
@@ -90,8 +90,31 @@ export type ChatConversation = {
|
|||||||
lastMessageText?: string | null;
|
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 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: {
|
export function usePilotChat(opts: {
|
||||||
apolloAuthReady: ComputedRef<boolean>;
|
apolloAuthReady: ComputedRef<boolean>;
|
||||||
authMe: Ref<any>;
|
authMe: Ref<any>;
|
||||||
@@ -191,24 +214,24 @@ export function usePilotChat(opts: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AI SDK chat instance
|
// AI SDK chat instance
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const pilotChat = new AiChat<UIMessage>({
|
const pilotChat = new AiChat<PilotUiMessage>({
|
||||||
transport: new DefaultChatTransport({
|
transport: new DefaultChatTransport({
|
||||||
api: "/api/pilot-chat",
|
api: "/api/pilot-chat",
|
||||||
}),
|
}),
|
||||||
onData: (part: any) => {
|
onData: (part) => {
|
||||||
if (part?.type !== "data-agent-log") return;
|
const log = parsePilotAgentLog(part);
|
||||||
const text = String(part?.data?.text ?? "").trim();
|
if (!log) return;
|
||||||
if (!text) return;
|
pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text: log.text, at: log.at }];
|
||||||
const at = String(part?.data?.at ?? new Date().toISOString());
|
|
||||||
pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text, at }];
|
|
||||||
},
|
},
|
||||||
onFinish: async () => {
|
onFinish: async () => {
|
||||||
|
pilotSending.value = false;
|
||||||
livePilotUserText.value = "";
|
livePilotUserText.value = "";
|
||||||
livePilotAssistantText.value = "";
|
livePilotAssistantText.value = "";
|
||||||
pilotLiveLogs.value = [];
|
pilotLiveLogs.value = [];
|
||||||
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
|
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
pilotSending.value = false;
|
||||||
if (livePilotUserText.value) {
|
if (livePilotUserText.value) {
|
||||||
pilotInput.value = livePilotUserText.value;
|
pilotInput.value = livePilotUserText.value;
|
||||||
}
|
}
|
||||||
@@ -285,7 +308,7 @@ export function usePilotChat(opts: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pilot ↔ UIMessage bridge
|
// Pilot ↔ UIMessage bridge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function pilotToUiMessage(message: PilotMessage): UIMessage {
|
function pilotToUiMessage(message: PilotMessage): PilotUiMessage {
|
||||||
return {
|
return {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
@@ -377,20 +400,8 @@ export function usePilotChat(opts: {
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
pilotInput.value = text;
|
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 = "";
|
livePilotUserText.value = "";
|
||||||
livePilotAssistantText.value = "";
|
|
||||||
pilotSending.value = false;
|
pilotSending.value = false;
|
||||||
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { readBody } from "h3";
|
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 { getAuthContext } from "../utils/auth";
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
||||||
@@ -9,53 +15,74 @@ import type { ChangeSet } from "../utils/changeSet";
|
|||||||
import { startPilotRun, addPilotTrace, finishPilotRun } from "../utils/pilotRunStore";
|
import { startPilotRun, addPilotTrace, finishPilotRun } from "../utils/pilotRunStore";
|
||||||
import { broadcastToConversation } from "../routes/ws/crm-updates";
|
import { broadcastToConversation } from "../routes/ws/crm-updates";
|
||||||
|
|
||||||
function extractMessageText(message: any): string {
|
type PilotDataTypes = {
|
||||||
if (!message || !Array.isArray(message.parts)) return "";
|
"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
|
return message.parts
|
||||||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
.filter((part): part is Extract<PilotUiMessage["parts"][number], { type: "text" }> => part.type === "text")
|
||||||
.map((part: any) => part.text)
|
.map((part) => part.text)
|
||||||
.join("")
|
.join("")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastUserText(messages: any[]): string {
|
function getLastUserText(messages: PilotUiMessage[]): string {
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
if (message?.role !== "user") continue;
|
if (message.role !== "user") continue;
|
||||||
const text = extractMessageText(message);
|
const text = extractMessageText(message);
|
||||||
if (text) return text;
|
if (text) return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
||||||
if (!raw || typeof raw !== "object") return 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 scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
|
||||||
const scopes = scopesRaw
|
const scopes = scopesRaw
|
||||||
.map((scope) => String(scope))
|
.map((scope) => String(scope))
|
||||||
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
||||||
|
|
||||||
if (!scopes.length) return null;
|
if (!scopes.length) return null;
|
||||||
|
|
||||||
const payload: PilotContextPayload = { scopes };
|
const payload: PilotContextPayload = { scopes };
|
||||||
|
|
||||||
if (item.summary && typeof item.summary === "object") {
|
if (item.summary && typeof item.summary === "object") {
|
||||||
const contactId = String(item.summary.contactId ?? "").trim();
|
const summary = item.summary as Record<string, unknown>;
|
||||||
const name = String(item.summary.name ?? "").trim();
|
const contactId = String(summary.contactId ?? "").trim();
|
||||||
|
const name = String(summary.name ?? "").trim();
|
||||||
if (contactId && name) payload.summary = { contactId, name };
|
if (contactId && name) payload.summary = { contactId, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.deal && typeof item.deal === "object") {
|
if (item.deal && typeof item.deal === "object") {
|
||||||
const dealId = String(item.deal.dealId ?? "").trim();
|
const deal = item.deal as Record<string, unknown>;
|
||||||
const title = String(item.deal.title ?? "").trim();
|
const dealId = String(deal.dealId ?? "").trim();
|
||||||
const contact = String(item.deal.contact ?? "").trim();
|
const title = String(deal.title ?? "").trim();
|
||||||
|
const contact = String(deal.contact ?? "").trim();
|
||||||
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.message && typeof item.message === "object") {
|
if (item.message && typeof item.message === "object") {
|
||||||
const contactId = String(item.message.contactId ?? "").trim();
|
const message = item.message as Record<string, unknown>;
|
||||||
const contact = String(item.message.contact ?? "").trim();
|
const contactId = String(message.contactId ?? "").trim();
|
||||||
const intent = String(item.message.intent ?? "").trim();
|
const contact = String(message.contact ?? "").trim();
|
||||||
|
const intent = String(message.intent ?? "").trim();
|
||||||
|
|
||||||
if (intent === "add_message_or_reminder") {
|
if (intent === "add_message_or_reminder") {
|
||||||
payload.message = {
|
payload.message = {
|
||||||
...(contactId ? { contactId } : {}),
|
...(contactId ? { contactId } : {}),
|
||||||
@@ -66,13 +93,15 @@ function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.calendar && typeof item.calendar === "object") {
|
if (item.calendar && typeof item.calendar === "object") {
|
||||||
const view = String(item.calendar.view ?? "").trim();
|
const calendar = item.calendar as Record<string, unknown>;
|
||||||
const period = String(item.calendar.period ?? "").trim();
|
const view = String(calendar.view ?? "").trim();
|
||||||
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
|
const period = String(calendar.period ?? "").trim();
|
||||||
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
|
const selectedDateKey = String(calendar.selectedDateKey ?? "").trim();
|
||||||
const eventIds = Array.isArray(item.calendar.eventIds)
|
const focusedEventId = String(calendar.focusedEventId ?? "").trim();
|
||||||
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
|
const eventIds = Array.isArray(calendar.eventIds)
|
||||||
|
? calendar.eventIds.map((id) => String(id ?? "").trim()).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
||||||
period &&
|
period &&
|
||||||
@@ -106,6 +135,7 @@ function humanizeTraceText(trace: AgentTraceEvent): string {
|
|||||||
|
|
||||||
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
||||||
const totals = { created: 0, updated: 0, deleted: 0 };
|
const totals = { created: 0, updated: 0, deleted: 0 };
|
||||||
|
|
||||||
for (const item of changeSet.items) {
|
for (const item of changeSet.items) {
|
||||||
if (item.action === "created") totals.created += 1;
|
if (item.action === "created") totals.created += 1;
|
||||||
else if (item.action === "updated") totals.updated += 1;
|
else if (item.action === "updated") totals.updated += 1;
|
||||||
@@ -126,23 +156,54 @@ function renderChangeSetSummary(changeSet: ChangeSet): string {
|
|||||||
return lines.join("\n");
|
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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const auth = await getAuthContext(event);
|
const auth = await getAuthContext(event);
|
||||||
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
|
const body = await readBody<PilotChatRequestBody>(event);
|
||||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
||||||
|
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 userText = getLastUserText(messages);
|
||||||
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
||||||
|
|
||||||
if (!userText) {
|
if (!userText) {
|
||||||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
const stream = createUIMessageStream<PilotUiMessage>({
|
||||||
execute: async ({ writer }) => {
|
execute: async ({ writer }) => {
|
||||||
const textId = `text-${Date.now()}`;
|
const textId = `text-${Date.now()}`;
|
||||||
|
|
||||||
writer.write({ type: "start" });
|
writer.write({ type: "start" });
|
||||||
startPilotRun(auth.conversationId);
|
startPilotRun(auth.conversationId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
||||||
|
|
||||||
@@ -168,10 +229,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
onTrace: async (trace: AgentTraceEvent) => {
|
onTrace: async (trace: AgentTraceEvent) => {
|
||||||
const traceText = humanizeTraceText(trace);
|
const traceText = humanizeTraceText(trace);
|
||||||
const traceAt = new Date().toISOString();
|
const traceAt = new Date().toISOString();
|
||||||
writer.write({
|
|
||||||
type: "data-agent-log",
|
writePilotLog(writer, {
|
||||||
data: { requestId, at: traceAt, text: traceText },
|
requestId,
|
||||||
|
at: traceAt,
|
||||||
|
text: traceText,
|
||||||
});
|
});
|
||||||
|
|
||||||
addPilotTrace(auth.conversationId, traceText);
|
addPilotTrace(auth.conversationId, traceText);
|
||||||
broadcastToConversation(auth.conversationId, {
|
broadcastToConversation(auth.conversationId, {
|
||||||
type: "pilot.trace",
|
type: "pilot.trace",
|
||||||
@@ -212,15 +276,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
finishPilotRun(auth.conversationId, "finished");
|
finalizePilotExecution(auth.conversationId, "finished");
|
||||||
broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() });
|
|
||||||
|
|
||||||
writer.write({ type: "text-start", id: textId });
|
writeAssistantText(writer, textId, reply.text);
|
||||||
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
|
||||||
writer.write({ type: "text-end", id: textId });
|
|
||||||
writer.write({ type: "finish", finishReason: "stop" });
|
writer.write({ type: "finish", finishReason: "stop" });
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
const errorText = String(error?.message ?? error);
|
const errorText = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
await persistAiMessage({
|
await persistAiMessage({
|
||||||
teamId: auth.teamId,
|
teamId: auth.teamId,
|
||||||
@@ -232,27 +293,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
eventType: "assistant",
|
eventType: "assistant",
|
||||||
phase: "error",
|
phase: "error",
|
||||||
transient: false,
|
transient: false,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
finalizePilotExecution(auth.conversationId, "error");
|
||||||
|
|
||||||
|
writePilotLog(writer, {
|
||||||
|
requestId,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
text: "Ошибка выполнения агентского цикла.",
|
||||||
});
|
});
|
||||||
|
|
||||||
finishPilotRun(auth.conversationId, "error");
|
writeAssistantText(writer, textId, errorText);
|
||||||
broadcastToConversation(auth.conversationId, { type: "pilot.finished", at: new Date().toISOString() });
|
writer.write({ type: "finish", finishReason: "error" });
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user