When a context scope is picked via the pipette, the picker mode now turns off automatically since the selection is done and shows as a chip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
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];
|
||
contextPickerEnabled.value = false;
|
||
}
|
||
|
||
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(() => {});
|
||
}, 30_000);
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|