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; authMe: Ref; selectedContact: ComputedRef; selectedDeal: ComputedRef; calendarView: Ref; calendarPeriodLabel: ComputedRef; selectedDateKey: Ref; focusedCalendarEvent: ComputedRef; calendarEvents: Ref; refetchAllCrmQueries: () => Promise; }) { // --------------------------------------------------------------------------- // State refs // --------------------------------------------------------------------------- const pilotMessages = ref([]); const pilotInput = ref(""); const pilotSending = ref(false); const pilotRecording = ref(false); const pilotTranscribing = ref(false); const pilotMicSupported = ref(false); const pilotMicError = ref(null); const pilotWaveContainer = ref(null); function setPilotWaveContainerRef(element: HTMLDivElement | null) { pilotWaveContainer.value = element; } const livePilotUserText = ref(""); const livePilotAssistantText = ref(""); const contextPickerEnabled = ref(false); const contextScopes = ref([]); const pilotLiveLogs = ref>([]); 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([]); 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 | 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({ 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, }; }