refactor: decompose CrmWorkspaceApp.vue into 15 composables

Split the 6000+ line monolithic component into modular composables:
- crm-types.ts: shared types and utility functions
- useAuth, useContacts, useContactInboxes, useCalendar, useDeals,
  useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio,
  usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting
CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that
wires composables together with glue code, keeping template and
styles intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 15:05:01 +07:00
parent e5ad3809e0
commit a4d8d81de9
16 changed files with 5643 additions and 4684 deletions

View File

@@ -0,0 +1,626 @@
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 {
PilotMessage,
ChatConversation,
ContextScope,
PilotContextPayload,
CalendarView,
Contact,
CalendarEvent,
Deal,
} from "~/composables/crm-types";
import { safeTrim } from "~/composables/crm-types";
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,
};
}