Files
clientsflow/frontend/app/composables/usePilotChat.ts
Ruslan Bakiev d892d0c604 refactor: distribute types from crm-types.ts to owning composables
Each composable now owns its types and exports them. Other composables
import types from the owning composable. Deleted centralized crm-types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:30 +07:00

695 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ref, computed, watch, watchEffect, nextTick, type Ref, type ComputedRef } from "vue";
import { useQuery, useMutation } from "@vue/apollo-composable";
import {
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
CreateChatConversationMutationDocument,
SelectChatConversationMutationDocument,
ArchiveChatConversationMutationDocument,
LogPilotNoteMutationDocument,
MeQueryDocument,
} from "~~/graphql/generated";
import { Chat as AiChat } from "@ai-sdk/vue";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
import type { Contact } from "~/composables/useContacts";
import type { CalendarView, CalendarEvent } from "~/composables/useCalendar";
import type { Deal } from "~/composables/useDeals";
export type PilotChangeItem = {
id: string;
entity: string;
entityId?: string | null;
action: string;
title: string;
before: string;
after: string;
rolledBack?: boolean;
};
export type PilotMessage = {
id: string;
role: "user" | "assistant" | "system";
text: string;
messageKind?: string | null;
requestId?: string | null;
eventType?: string | null;
phase?: string | null;
transient?: boolean | null;
thinking?: string[] | null;
tools?: string[] | null;
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}> | null;
changeSetId?: string | null;
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
changeSummary?: string | null;
changeItems?: PilotChangeItem[] | null;
createdAt?: string;
_live?: boolean;
};
export type ContextScope = "summary" | "deal" | "message" | "calendar";
export type PilotContextPayload = {
scopes: ContextScope[];
summary?: {
contactId: string;
name: string;
};
deal?: {
dealId: string;
title: string;
contact: string;
};
message?: {
contactId?: string;
contact?: string;
intent: "add_message_or_reminder";
};
calendar?: {
view: CalendarView;
period: string;
selectedDateKey: string;
focusedEventId?: string;
eventIds: string[];
};
};
export type ChatConversation = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
lastMessageAt?: string | null;
lastMessageText?: string | null;
};
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
export function usePilotChat(opts: {
apolloAuthReady: ComputedRef<boolean>;
authMe: Ref<any>;
selectedContact: ComputedRef<Contact | null>;
selectedDeal: ComputedRef<Deal | null>;
calendarView: Ref<CalendarView>;
calendarPeriodLabel: ComputedRef<string>;
selectedDateKey: Ref<string>;
focusedCalendarEvent: ComputedRef<CalendarEvent | null>;
calendarEvents: Ref<CalendarEvent[]>;
refetchAllCrmQueries: () => Promise<void>;
}) {
// ---------------------------------------------------------------------------
// State refs
// ---------------------------------------------------------------------------
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const pilotRecording = ref(false);
const pilotTranscribing = ref(false);
const pilotMicSupported = ref(false);
const pilotMicError = ref<string | null>(null);
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
function setPilotWaveContainerRef(element: HTMLDivElement | null) {
pilotWaveContainer.value = element;
}
const livePilotUserText = ref("");
const livePilotAssistantText = ref("");
const contextPickerEnabled = ref(false);
const contextScopes = ref<ContextScope[]>([]);
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
const pilotLiveLogsExpanded = ref(false);
const pilotLiveLogHiddenCount = computed(() => {
const hidden = pilotLiveLogs.value.length - PILOT_LIVE_LOGS_PREVIEW_LIMIT;
return hidden > 0 ? hidden : 0;
});
const pilotVisibleLiveLogs = computed(() => {
if (pilotLiveLogsExpanded.value || pilotLiveLogHiddenCount.value === 0) return pilotLiveLogs.value;
return pilotLiveLogs.value.slice(-PILOT_LIVE_LOGS_PREVIEW_LIMIT);
});
const pilotVisibleLogCount = computed(() =>
Math.min(pilotLiveLogs.value.length, PILOT_LIVE_LOGS_PREVIEW_LIMIT),
);
const chatConversations = ref<ChatConversation[]>([]);
const chatThreadsLoading = ref(false);
const chatSwitching = ref(false);
const chatCreating = ref(false);
const chatArchivingId = ref("");
const chatThreadPickerOpen = ref(false);
const selectedChatId = ref("");
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
// ---------------------------------------------------------------------------
// Media recorder vars (non-reactive)
// ---------------------------------------------------------------------------
let pilotMediaRecorder: MediaRecorder | null = null;
let pilotRecorderStream: MediaStream | null = null;
let pilotRecordingChunks: Blob[] = [];
let pilotRecorderMimeType = "audio/webm";
let pilotRecordingFinishMode: "fill" | "send" = "fill";
let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
let pilotWaveSurfer: any = null;
let pilotWaveRecordPlugin: any = null;
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
// ---------------------------------------------------------------------------
// Apollo Queries
// ---------------------------------------------------------------------------
const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
ChatMessagesQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
ChatConversationsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
const { mutate: doCreateChatConversation } = useMutation(CreateChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doSelectChatConversation } = useMutation(SelectChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
const { mutate: doArchiveChatConversation } = useMutation(ArchiveChatConversationMutationDocument, {
refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
});
// ---------------------------------------------------------------------------
// AI SDK chat instance
// ---------------------------------------------------------------------------
const pilotChat = new AiChat<UIMessage>({
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 }];
},
onFinish: async () => {
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
},
onError: () => {
if (livePilotUserText.value) {
pilotInput.value = livePilotUserText.value;
}
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
},
});
// ---------------------------------------------------------------------------
// Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
// ---------------------------------------------------------------------------
watch(() => chatMessagesResult.value?.chatMessages, (v) => {
if (v) {
pilotMessages.value = v as PilotMessage[];
syncPilotChatFromHistory(pilotMessages.value);
}
}, { immediate: true });
watch(() => chatConversationsResult.value?.chatConversations, (v) => {
if (v) chatConversations.value = v as ChatConversation[];
}, { immediate: true });
watch(
() => pilotLiveLogs.value.length,
(len) => {
if (len === 0 || len <= PILOT_LIVE_LOGS_PREVIEW_LIMIT) {
pilotLiveLogsExpanded.value = false;
}
},
);
// Live assistant text watcher
watchEffect(() => {
if (!pilotSending.value) return;
const latestAssistant = [...pilotChat.messages]
.reverse()
.find((message) => message.role === "assistant");
if (!latestAssistant) return;
const textPart = latestAssistant.parts.find(isTextUIPart);
livePilotAssistantText.value = textPart?.text ?? "";
});
// ---------------------------------------------------------------------------
// Context picker
// ---------------------------------------------------------------------------
function toggleContextPicker() {
contextPickerEnabled.value = !contextPickerEnabled.value;
}
function hasContextScope(scope: ContextScope) {
return contextScopes.value.includes(scope);
}
function toggleContextScope(scope: ContextScope) {
if (!contextPickerEnabled.value) return;
if (hasContextScope(scope)) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
return;
}
contextScopes.value = [...contextScopes.value, scope];
}
function removeContextScope(scope: ContextScope) {
contextScopes.value = contextScopes.value.filter((item) => item !== scope);
}
function togglePilotLiveLogsExpanded() {
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
}
// ---------------------------------------------------------------------------
// Pilot ↔ UIMessage bridge
// ---------------------------------------------------------------------------
function pilotToUiMessage(message: PilotMessage): UIMessage {
return {
id: message.id,
role: message.role,
parts: [{ type: "text", text: message.text }],
metadata: {
createdAt: message.createdAt ?? null,
},
};
}
function syncPilotChatFromHistory(messages: PilotMessage[]) {
pilotChat.messages = messages.filter((m) => m.role !== "system").map(pilotToUiMessage);
}
// ---------------------------------------------------------------------------
// Context payload builder
// ---------------------------------------------------------------------------
function buildContextPayload(): PilotContextPayload | null {
const scopes = [...contextScopes.value];
if (!scopes.length) return null;
const payload: PilotContextPayload = { scopes };
if (hasContextScope("summary") && opts.selectedContact.value) {
payload.summary = {
contactId: opts.selectedContact.value.id,
name: opts.selectedContact.value.name,
};
}
if (hasContextScope("deal") && opts.selectedDeal.value) {
payload.deal = {
dealId: opts.selectedDeal.value.id,
title: opts.selectedDeal.value.title,
contact: opts.selectedDeal.value.contact,
};
}
if (hasContextScope("message")) {
payload.message = {
contactId: opts.selectedContact.value?.id || undefined,
contact: opts.selectedContact.value?.name || undefined,
intent: "add_message_or_reminder",
};
}
if (hasContextScope("calendar")) {
const eventIds = (() => {
if (opts.focusedCalendarEvent.value) return [opts.focusedCalendarEvent.value.id];
return opts.calendarEvents.value.map((event) => event.id);
})();
payload.calendar = {
view: opts.calendarView.value,
period: opts.calendarPeriodLabel.value,
selectedDateKey: opts.selectedDateKey.value,
focusedEventId: opts.focusedCalendarEvent.value?.id || undefined,
eventIds,
};
}
return payload;
}
// ---------------------------------------------------------------------------
// Send pilot message
// ---------------------------------------------------------------------------
async function sendPilotText(rawText: string) {
const text = safeTrim(rawText);
if (!text || pilotSending.value) return;
const contextPayload = buildContextPayload();
pilotSending.value = true;
pilotInput.value = "";
livePilotUserText.value = text;
livePilotAssistantText.value = "";
pilotLiveLogsExpanded.value = false;
pilotLiveLogs.value = [];
try {
await pilotChat.sendMessage(
{ text },
contextPayload
? {
body: {
contextPayload,
},
}
: undefined,
);
} 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()]);
}
}
async function sendPilotMessage() {
await sendPilotText(pilotInput.value);
}
// ---------------------------------------------------------------------------
// WaveSurfer lazy loading for mic
// ---------------------------------------------------------------------------
async function loadWaveSurferModules() {
if (!waveSurferModulesPromise) {
waveSurferModulesPromise = Promise.all([
import("wavesurfer.js"),
import("wavesurfer.js/dist/plugins/record.esm.js"),
]).then(([ws, rec]) => ({
WaveSurfer: ws.default,
RecordPlugin: rec.default,
}));
}
return waveSurferModulesPromise;
}
async function ensurePilotWaveSurfer() {
if (pilotWaveSurfer && pilotWaveRecordPlugin) return;
if (!pilotWaveContainer.value) return;
const { WaveSurfer, RecordPlugin } = await loadWaveSurferModules();
pilotWaveSurfer = WaveSurfer.create({
container: pilotWaveContainer.value,
height: 22,
waveColor: "rgba(208, 226, 255, 0.95)",
progressColor: "rgba(141, 177, 255, 0.95)",
cursorWidth: 0,
normalize: true,
interact: false,
});
pilotWaveRecordPlugin = pilotWaveSurfer.registerPlugin(
RecordPlugin.create({
renderRecordedAudio: false,
scrollingWaveform: true,
scrollingWaveformWindow: 10,
mediaRecorderTimeslice: 250,
}),
);
}
async function stopPilotMeter() {
if (pilotWaveMicSession) {
pilotWaveMicSession.onDestroy();
pilotWaveMicSession = null;
}
}
async function startPilotMeter(stream: MediaStream) {
await nextTick();
await ensurePilotWaveSurfer();
await stopPilotMeter();
if (!pilotWaveRecordPlugin) return;
pilotWaveMicSession = pilotWaveRecordPlugin.renderMicStream(stream);
}
function destroyPilotWaveSurfer() {
stopPilotMeter();
if (pilotWaveSurfer) {
pilotWaveSurfer.destroy();
pilotWaveSurfer = null;
pilotWaveRecordPlugin = null;
}
}
// ---------------------------------------------------------------------------
// Audio recording & transcription
// ---------------------------------------------------------------------------
function appendPilotTranscript(text: string) {
const next = safeTrim(text);
if (!next) return "";
const merged = pilotInput.value.trim() ? `${pilotInput.value.trim()} ${next}` : next;
pilotInput.value = merged;
return merged;
}
async function transcribeRecordedPilotAudio(blob: Blob) {
pilotMicError.value = null;
pilotTranscribing.value = true;
try {
const text = await transcribeAudioBlob(blob);
if (!text) {
pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
return null;
}
return text;
} catch (error: any) {
pilotMicError.value = String(error?.data?.message ?? error?.message ?? "Ошибка распознавания аудио");
return null;
} finally {
pilotTranscribing.value = false;
}
}
async function startPilotRecording() {
if (pilotRecording.value || pilotTranscribing.value) return;
pilotMicError.value = null;
if (!pilotMicSupported.value) {
pilotMicError.value = "Запись не поддерживается в этом браузере.";
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMime = "audio/webm;codecs=opus";
const recorder = MediaRecorder.isTypeSupported(preferredMime)
? new MediaRecorder(stream, { mimeType: preferredMime })
: new MediaRecorder(stream);
pilotRecorderStream = stream;
pilotRecorderMimeType = recorder.mimeType || "audio/webm";
pilotMediaRecorder = recorder;
pilotRecordingFinishMode = "fill";
pilotRecordingChunks = [];
pilotRecording.value = true;
void startPilotMeter(stream);
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data?.size) pilotRecordingChunks.push(event.data);
};
recorder.onstop = async () => {
pilotRecording.value = false;
await stopPilotMeter();
const mode = pilotRecordingFinishMode;
pilotRecordingFinishMode = "fill";
const audioBlob = new Blob(pilotRecordingChunks, { type: pilotRecorderMimeType });
pilotRecordingChunks = [];
pilotMediaRecorder = null;
if (pilotRecorderStream) {
pilotRecorderStream.getTracks().forEach((track) => track.stop());
pilotRecorderStream = null;
}
if (audioBlob.size > 0) {
const transcript = await transcribeRecordedPilotAudio(audioBlob);
if (!transcript) return;
const mergedText = appendPilotTranscript(transcript);
if (mode === "send" && !pilotSending.value && mergedText.trim()) {
await sendPilotText(mergedText);
return;
}
}
};
recorder.start();
} catch {
pilotMicError.value = "Нет доступа к микрофону.";
pilotRecording.value = false;
}
}
function stopPilotRecording(mode: "fill" | "send" = "fill") {
if (!pilotMediaRecorder || pilotMediaRecorder.state === "inactive") return;
pilotRecordingFinishMode = mode;
pilotRecording.value = false;
pilotMediaRecorder.stop();
}
function togglePilotRecording() {
if (pilotRecording.value) {
stopPilotRecording("fill");
} else {
startPilotRecording();
}
}
// ---------------------------------------------------------------------------
// Chat conversation management
// ---------------------------------------------------------------------------
function toggleChatThreadPicker() {
if (chatSwitching.value || chatThreadsLoading.value || chatConversations.value.length === 0) return;
chatThreadPickerOpen.value = !chatThreadPickerOpen.value;
}
function closeChatThreadPicker() {
chatThreadPickerOpen.value = false;
}
async function createNewChatConversation() {
if (chatCreating.value) return;
chatThreadPickerOpen.value = false;
chatCreating.value = true;
try {
await doCreateChatConversation();
} finally {
chatCreating.value = false;
}
}
async function switchChatConversation(id: string) {
if (!id || chatSwitching.value || opts.authMe.value?.conversation.id === id) return;
chatThreadPickerOpen.value = false;
chatSwitching.value = true;
try {
await doSelectChatConversation({ id });
} finally {
chatSwitching.value = false;
}
}
async function archiveChatConversation(id: string) {
if (!id || chatArchivingId.value) return;
chatArchivingId.value = id;
try {
await doArchiveChatConversation({ id });
} finally {
chatArchivingId.value = "";
}
}
// ---------------------------------------------------------------------------
// Background polling
// ---------------------------------------------------------------------------
async function loadPilotMessages() {
await refetchChatMessages();
}
function startPilotBackgroundPolling() {
if (pilotBackgroundPoll) return;
pilotBackgroundPoll = setInterval(() => {
if (!opts.authMe.value) return;
loadPilotMessages().catch(() => {});
}, 2000);
}
function stopPilotBackgroundPolling() {
if (!pilotBackgroundPoll) return;
clearInterval(pilotBackgroundPoll);
pilotBackgroundPoll = null;
}
// ---------------------------------------------------------------------------
// Fire-and-forget pilot note
// ---------------------------------------------------------------------------
function pushPilotNote(text: string) {
// Fire-and-forget: log assistant note to the same conversation.
doLogPilotNote({ text })
.then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
.catch(() => {});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
pilotMessages,
pilotInput,
pilotSending,
pilotRecording,
pilotTranscribing,
pilotMicSupported,
pilotMicError,
pilotWaveContainer,
setPilotWaveContainerRef,
livePilotUserText,
livePilotAssistantText,
contextPickerEnabled,
contextScopes,
pilotLiveLogs,
pilotLiveLogsExpanded,
pilotLiveLogHiddenCount,
pilotVisibleLiveLogs,
pilotVisibleLogCount,
chatConversations,
chatThreadsLoading,
chatSwitching,
chatCreating,
selectedChatId,
chatThreadPickerOpen,
chatArchivingId,
toggleContextPicker,
hasContextScope,
toggleContextScope,
removeContextScope,
togglePilotLiveLogsExpanded,
sendPilotText,
sendPilotMessage,
startPilotRecording,
stopPilotRecording,
togglePilotRecording,
createNewChatConversation,
switchChatConversation,
archiveChatConversation,
toggleChatThreadPicker,
closeChatThreadPicker,
startPilotBackgroundPolling,
stopPilotBackgroundPolling,
buildContextPayload,
pushPilotNote,
refetchChatMessages,
refetchChatConversations,
// cleanup
destroyPilotWaveSurfer,
};
}