diff --git a/src/api/posthogClient.ts b/src/api/posthogClient.ts index bee8858e..3712e0e8 100644 --- a/src/api/posthogClient.ts +++ b/src/api/posthogClient.ts @@ -400,4 +400,37 @@ export class PostHogAPIClient { return await response.json(); } + + async uploadDesktopRecordingTranscript( + recordingId: string, + transcript: { + full_text: string; + segments: Array<{ + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + }>; + }, + ) { + this.validateRecordingId(recordingId); + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`, + parameters: { + body: transcript, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to upload transcript: ${response.statusText}`); + } + + return await response.json(); + } } diff --git a/src/main/preload.ts b/src/main/preload.ts index 346bf153..658a3ec4 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -202,6 +202,36 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("notetaker:get-recording", recordingId), notetakerDeleteRecording: (recordingId: string): Promise => ipcRenderer.invoke("notetaker:delete-recording", recordingId), + // Real-time transcript listener + onTranscriptSegment: ( + listener: (segment: { + posthog_recording_id: string; + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + is_final: boolean; + }) => void, + ): (() => void) => { + const channel = "recall:transcript-segment"; + const wrapped = (_event: IpcRendererEvent, segment: unknown) => + listener(segment as never); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + // Meeting ended listener (trigger sync) + onMeetingEnded: ( + listener: (event: { + posthog_recording_id: string; + platform: string; + }) => void, + ): (() => void) => { + const channel = "recall:meeting-ended"; + const wrapped = (_event: IpcRendererEvent, event: unknown) => + listener(event as never); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, // Shell API shellCreate: (sessionId: string, cwd?: string): Promise => ipcRenderer.invoke("shell:create", sessionId, cwd), diff --git a/src/renderer/features/notetaker/components/LiveTranscriptView.tsx b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx new file mode 100644 index 00000000..7e8a764f --- /dev/null +++ b/src/renderer/features/notetaker/components/LiveTranscriptView.tsx @@ -0,0 +1,213 @@ +import { Badge, Box, Card, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { useEffect, useRef, useState } from "react"; +import { useLiveTranscript } from "../hooks/useTranscript"; + +interface LiveTranscriptViewProps { + posthogRecordingId: string; // PostHog UUID +} + +export function LiveTranscriptView({ + posthogRecordingId, +}: LiveTranscriptViewProps) { + const scrollRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + const { segments, addSegment, forceUpload, pendingCount } = + useLiveTranscript(posthogRecordingId); + + // Auto-scroll to bottom when new segments arrive + useEffect(() => { + if (autoScroll && scrollRef.current && segments.length > 0) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [segments.length, autoScroll]); + + // Listen for new transcript segments from IPC + useEffect(() => { + console.log( + `[LiveTranscript] Setting up listener for recording ${posthogRecordingId}`, + ); + + const cleanup = window.electronAPI.onTranscriptSegment((segment) => { + console.log( + `[LiveTranscript] Received segment for ${segment.posthog_recording_id}:`, + segment.text, + ); + + if (segment.posthog_recording_id !== posthogRecordingId) { + console.log( + `[LiveTranscript] Ignoring segment - not for this recording (${posthogRecordingId})`, + ); + return; // Not for this recording + } + + console.log("[LiveTranscript] Adding segment to local buffer"); + + // Add to local buffer + addSegment({ + timestamp: segment.timestamp, + speaker: segment.speaker, + text: segment.text, + confidence: segment.confidence, + }); + }); + + return cleanup; + }, [posthogRecordingId, addSegment]); + + // Listen for meeting-ended event to force upload remaining segments + useEffect(() => { + console.log( + `[LiveTranscript] Setting up meeting-ended listener for ${posthogRecordingId}`, + ); + + const cleanup = window.electronAPI.onMeetingEnded((event) => { + if (event.posthog_recording_id === posthogRecordingId) { + console.log(`[LiveTranscript] Meeting ended, force uploading segments`); + forceUpload(); + } + }); + + return cleanup; + }, [posthogRecordingId, forceUpload]); + + // Detect manual scroll to disable auto-scroll + const handleScroll = () => { + if (!scrollRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isNearBottom); + }; + + if (!segments || segments.length === 0) { + return ( + + + + Waiting for transcript... + + + + ); + } + + return ( + + + + Live transcript + + + {pendingCount > 0 && ( + + {pendingCount} pending upload + + )} + + {segments.length} segments + + + + + + + {segments.map((segment, idx) => { + const prevSegment = idx > 0 ? segments[idx - 1] : null; + const isSameSpeaker = prevSegment?.speaker === segment.speaker; + + return ( + + {/* Speaker name (only show if different from previous) */} + + {!isSameSpeaker && segment.speaker && ( + + {segment.speaker} + + )} + + + {/* Timestamp + Text */} + + + + {formatTimestamp(segment.timestamp)} + + + {segment.text} + + + + + ); + })} + + + + {!autoScroll && ( + + + Scroll to bottom to enable auto-scroll + + + )} + + ); +} + +function formatTimestamp(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +// Consistent color assignment for speakers +function getSpeakerColor(speaker: string): string { + const colors = [ + "var(--blue-11)", + "var(--green-11)", + "var(--orange-11)", + "var(--purple-11)", + "var(--pink-11)", + "var(--cyan-11)", + ]; + + // Simple hash function to consistently map speaker to color + let hash = 0; + for (let i = 0; i < speaker.length; i++) { + hash = speaker.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} diff --git a/src/renderer/features/notetaker/components/NotetakerView.tsx b/src/renderer/features/notetaker/components/NotetakerView.tsx index 6010876e..11734728 100644 --- a/src/renderer/features/notetaker/components/NotetakerView.tsx +++ b/src/renderer/features/notetaker/components/NotetakerView.tsx @@ -1,4 +1,5 @@ import { + ArrowClockwiseIcon, CheckCircleIcon, ClockIcon, TrashIcon, @@ -11,12 +12,13 @@ import { Button, Card, Flex, - ScrollArea, Spinner, Text, } from "@radix-ui/themes"; -import { useEffect } from "react"; -import { useNotetakerStore } from "../stores/notetakerStore"; +import { useState } from "react"; +import { useDeleteRecording, useRecordings } from "../hooks/useRecordings"; +import { useUploadTranscript } from "../hooks/useTranscript"; +import { LiveTranscriptView } from "./LiveTranscriptView"; function getStatusIcon( status: "recording" | "uploading" | "processing" | "ready" | "error", @@ -74,21 +76,33 @@ function formatDuration(seconds: number | null) { } export function NotetakerView() { - const { - recordings, - isLoading, - error, - fetchRecordings, - deleteRecording, - selectedRecordingId, - setSelectedRecording, - } = useNotetakerStore(); - - useEffect(() => { - fetchRecordings(); - const interval = setInterval(fetchRecordings, 10000); - return () => clearInterval(interval); - }, [fetchRecordings]); + const { data: recordings = [], isLoading, error, refetch } = useRecordings(); + const deleteMutation = useDeleteRecording(); + const uploadTranscriptMutation = useUploadTranscript(); + const [selectedRecordingId, setSelectedRecordingId] = useState( + null, + ); + + const handleRetry = (recordingId: string) => { + // For retry, we need to fetch local segments and upload + // Since we're now backend-first, this will trigger a re-sync + uploadTranscriptMutation.mutate({ + recordingId, + segments: [], // Empty will fetch from backend + }); + }; + + const handleDelete = (recordingId: string) => { + if (confirm("Delete this recording?")) { + deleteMutation.mutate(recordingId, { + onSuccess: () => { + if (selectedRecordingId === recordingId) { + setSelectedRecordingId(null); + } + }, + }); + } + }; if (isLoading && recordings.length === 0) { return ( @@ -106,8 +120,8 @@ export function NotetakerView() { - {error} - + {String(error)} + ); @@ -127,23 +141,31 @@ export function NotetakerView() { ); } + const selectedRecording = recordings.find( + (r) => r.id === selectedRecordingId, + ); + return ( - - - - - Notetaker - - - {recordings.length} recording{recordings.length !== 1 ? "s" : ""} - - + + {/* Left sidebar: Recordings list */} + + + + + Notetaker + + + {recordings.length} recording{recordings.length !== 1 ? "s" : ""} + + - {recordings.map((recording) => ( setSelectedRecording(recording.id)} + onClick={() => setSelectedRecordingId(recording.id)} > @@ -190,25 +212,45 @@ export function NotetakerView() { - + + {recording.status === "error" && ( + + )} + + ))} - - - + + + + {/* Right panel: Live transcript view */} + {selectedRecordingId && selectedRecording && ( + + + + )} + ); } diff --git a/src/renderer/features/notetaker/hooks/useRecordings.ts b/src/renderer/features/notetaker/hooks/useRecordings.ts new file mode 100644 index 00000000..ea20f354 --- /dev/null +++ b/src/renderer/features/notetaker/hooks/useRecordings.ts @@ -0,0 +1,72 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "../../../stores/authStore"; + +export interface Recording { + id: string; + platform: string; + title: string | null; + status: "recording" | "uploading" | "processing" | "ready" | "error"; + created_at: string; + duration: number | null; + video_url: string | null; + recall_recording_id: string | null; +} + +export function useRecordings() { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-recordings"], // Unique key to avoid collision with legacy recordings + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + const recordings = await client.listDesktopRecordings(); + return recordings as Recording[]; + }, + enabled: !!client, + refetchInterval: 10000, // Poll every 10s + staleTime: 5000, // Consider stale after 5s + }); +} + +export function useRecording(recordingId: string | null) { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-recording", recordingId], // Unique key + queryFn: async () => { + if (!client || !recordingId) + throw new Error("Not authenticated or no recording ID"); + const recording = await client.getDesktopRecording(recordingId); + return recording as Recording; + }, + enabled: !!client && !!recordingId, + refetchInterval: (query) => { + const status = query.state.data?.status; + // Poll faster during active states + if ( + status === "recording" || + status === "uploading" || + status === "processing" + ) { + return 2000; // 2s + } + return 30000; // 30s for ready/error + }, + }); +} + +export function useDeleteRecording() { + const { client } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (recordingId: string) => { + if (!client) throw new Error("Not authenticated"); + await client.deleteDesktopRecording(recordingId); + }, + onSuccess: () => { + // Invalidate recordings list + queryClient.invalidateQueries({ queryKey: ["notetaker-recordings"] }); + }, + }); +} diff --git a/src/renderer/features/notetaker/hooks/useTranscript.ts b/src/renderer/features/notetaker/hooks/useTranscript.ts new file mode 100644 index 00000000..ba69f72d --- /dev/null +++ b/src/renderer/features/notetaker/hooks/useTranscript.ts @@ -0,0 +1,181 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; +import { useAuthStore } from "../../../stores/authStore"; + +export interface TranscriptSegment { + timestamp: number; // Milliseconds from start + speaker: string | null; + text: string; + confidence: number | null; +} + +interface TranscriptData { + full_text: string; + segments: TranscriptSegment[]; +} + +export function useTranscript(recordingId: string | null) { + const { client } = useAuthStore(); + + return useQuery({ + queryKey: ["notetaker-transcript", recordingId], // Unique key + queryFn: async () => { + if (!client || !recordingId) + throw new Error("Not authenticated or no recording ID"); + const transcript = + await client.getDesktopRecordingTranscript(recordingId); + return transcript as TranscriptData; + }, + enabled: !!client && !!recordingId, + refetchInterval: false, // Disabled: we get real-time updates via IPC events + staleTime: Infinity, // Never consider stale since we update via IPC + }); +} + +export function useUploadTranscript() { + const { client } = useAuthStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + recordingId, + segments, + }: { + recordingId: string; + segments: TranscriptSegment[]; + }) => { + if (!client) throw new Error("Not authenticated"); + + const full_text = segments.map((s) => s.text).join(" "); + + return await client.uploadDesktopRecordingTranscript(recordingId, { + full_text, + segments, + }); + }, + onSuccess: (_data, variables) => { + // Invalidate transcript query to refetch + queryClient.invalidateQueries({ + queryKey: ["notetaker-transcript", variables.recordingId], + }); + queryClient.invalidateQueries({ + queryKey: ["notetaker-recording", variables.recordingId], + }); + queryClient.invalidateQueries({ queryKey: ["notetaker-recordings"] }); + }, + }); +} + +/** + * Hook for managing real-time transcript with TanStack Query cache and batched uploads + */ +export function useLiveTranscript(posthogRecordingId: string | null) { + const queryClient = useQueryClient(); + const uploadMutation = useUploadTranscript(); + const { data: serverTranscript } = useTranscript(posthogRecordingId); + + // Track pending segments for upload + const pendingSegmentsRef = useRef([]); + const uploadTimerRef = useRef(); + const posthogRecordingIdRef = useRef(posthogRecordingId); + posthogRecordingIdRef.current = posthogRecordingId; + + // Upload pending segments helper + const doUpload = useCallback(() => { + if ( + !posthogRecordingIdRef.current || + pendingSegmentsRef.current.length === 0 + ) + return; + + const toUpload = [...pendingSegmentsRef.current]; + pendingSegmentsRef.current = []; + + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + uploadTimerRef.current = undefined; + } + + uploadMutation.mutate({ + recordingId: posthogRecordingIdRef.current, + segments: toUpload, + }); + }, [uploadMutation]); + + // Add new segment from IPC - optimistically update cache + const addSegment = useCallback( + (segment: TranscriptSegment) => { + if (!posthogRecordingId) return; + + // Optimistically update TanStack Query cache + queryClient.setQueryData( + ["notetaker-transcript", posthogRecordingId], + (old) => { + const existingSegments = old?.segments || []; + + // Check if segment already exists + const exists = existingSegments.some( + (s) => s.timestamp === segment.timestamp, + ); + if (exists) return old; + + // Add to cache + const newSegments = [...existingSegments, segment].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + return { + full_text: newSegments.map((s) => s.text).join(" "), + segments: newSegments, + }; + }, + ); + + // Track for batched upload + const alreadyPending = pendingSegmentsRef.current.some( + (s) => s.timestamp === segment.timestamp, + ); + if (!alreadyPending) { + pendingSegmentsRef.current.push(segment); + } + + // Clear existing timer + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + } + + // Upload immediately if we have 10 segments + if (pendingSegmentsRef.current.length >= 10) { + doUpload(); + } else { + // Otherwise schedule upload in 10 seconds + uploadTimerRef.current = setTimeout(() => { + doUpload(); + }, 10000); + } + }, + [posthogRecordingId, queryClient, doUpload], + ); + + // Force upload (e.g., when meeting ends) + const forceUpload = useCallback(() => { + doUpload(); + }, [doUpload]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (uploadTimerRef.current) { + clearTimeout(uploadTimerRef.current); + } + }; + }, []); + + return { + segments: serverTranscript?.segments || [], + addSegment, + forceUpload, + isUploading: uploadMutation.isPending, + pendingCount: pendingSegmentsRef.current.length, + }; +} diff --git a/src/renderer/features/notetaker/stores/notetakerStore.ts b/src/renderer/features/notetaker/stores/notetakerStore.ts deleted file mode 100644 index b603a37e..00000000 --- a/src/renderer/features/notetaker/stores/notetakerStore.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { create } from "zustand"; - -export interface DesktopRecording { - id: string; - platform: string; - title: string | null; - status: "recording" | "uploading" | "processing" | "ready" | "error"; - duration: number | null; - created_at: string; - video_url: string | null; - recall_recording_id: string | null; -} - -interface NotetakerState { - recordings: DesktopRecording[]; - isLoading: boolean; - error: string | null; - selectedRecordingId: string | null; - - fetchRecordings: () => Promise; - setSelectedRecording: (id: string | null) => void; - deleteRecording: (id: string) => Promise; - refreshRecording: (id: string) => Promise; -} - -export const useNotetakerStore = create((set, get) => ({ - recordings: [], - isLoading: false, - error: null, - selectedRecordingId: null, - - fetchRecordings: async () => { - set({ isLoading: true, error: null }); - try { - const recordings = await window.electronAPI.notetakerGetRecordings(); - set({ recordings, isLoading: false }); - } catch (error) { - console.error("[Notetaker] Failed to fetch recordings:", error); - set({ - error: - error instanceof Error ? error.message : "Failed to fetch recordings", - isLoading: false, - }); - } - }, - - setSelectedRecording: (id) => set({ selectedRecordingId: id }), - - deleteRecording: async (id: string) => { - try { - await window.electronAPI.notetakerDeleteRecording(id); - const { recordings } = get(); - set({ - recordings: recordings.filter((r) => r.id !== id), - selectedRecordingId: - get().selectedRecordingId === id ? null : get().selectedRecordingId, - }); - } catch (error) { - console.error("[Notetaker] Failed to delete recording:", error); - set({ - error: - error instanceof Error ? error.message : "Failed to delete recording", - }); - } - }, - - refreshRecording: async (id: string) => { - try { - const recording = await window.electronAPI.notetakerGetRecording(id); - const { recordings } = get(); - set({ - recordings: recordings.map((r) => (r.id === id ? recording : r)), - }); - } catch (error) { - console.error("[Notetaker] Failed to refresh recording:", error); - } - }, -})); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 95dac860..02730f74 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -148,6 +148,24 @@ export interface IElectronAPI { recall_recording_id: string | null; }>; notetakerDeleteRecording: (recordingId: string) => Promise; + // Real-time transcript listener + onTranscriptSegment: ( + listener: (segment: { + posthog_recording_id: string; + timestamp: number; + speaker: string | null; + text: string; + confidence: number | null; + is_final: boolean; + }) => void, + ) => () => void; + // Meeting ended listener (trigger sync) + onMeetingEnded: ( + listener: (event: { + posthog_recording_id: string; + platform: string; + }) => void, + ) => () => void; // Shell API shellCreate: (sessionId: string, cwd?: string) => Promise; shellWrite: (sessionId: string, data: string) => Promise;