Skip to content

Commit 51e8702

Browse files
committed
feat: real-time transcript streaming with TanStack Query
Refactored Notetaker from Dexie local-first to TanStack Query backend-first architecture with smart caching and real-time transcript streaming. **Architecture Changes:** - Removed Dexie (IndexedDB) dependency - Migrated to TanStack Query for server state management - Backend-first with PostHog as source of truth - Local buffering with batched uploads (10 segments / 10s) **Key Features:** - Real-time transcript segments from Recall.ai AssemblyAI stream - Smart polling: 2s during active recording, 10s for ready recordings - Local buffer prevents duplicate uploads - Optimistic UI updates with pending upload badge - Automatic query invalidation and cache management - Transcript view limited to 60vh (room for notes/action items) **Query Keys (unique to avoid collision with legacy Recordings):** - notetaker-recordings - notetaker-recording - notetaker-transcript **Files Added:** - src/renderer/features/notetaker/hooks/useRecordings.ts - src/renderer/features/notetaker/hooks/useTranscript.ts - src/renderer/features/notetaker/components/LiveTranscriptView.tsx **Files Removed:** - src/renderer/features/notetaker/stores/notetakerStore.ts - src/renderer/services/db.ts (Dexie schema) - src/renderer/services/transcriptSync.ts **Dependencies Removed:** - dexie - dexie-react-hooks
1 parent 5ffb727 commit 51e8702

File tree

9 files changed

+661
-133
lines changed

9 files changed

+661
-133
lines changed

src/api/posthogClient.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,4 +402,37 @@ export class PostHogAPIClient {
402402

403403
return await response.json();
404404
}
405+
406+
async uploadDesktopRecordingTranscript(
407+
recordingId: string,
408+
transcript: {
409+
full_text: string;
410+
segments: Array<{
411+
timestamp: number;
412+
speaker: string | null;
413+
text: string;
414+
confidence: number | null;
415+
}>;
416+
},
417+
) {
418+
this.validateRecordingId(recordingId);
419+
const teamId = await this.getTeamId();
420+
const url = new URL(
421+
`${this.api.baseUrl}/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`,
422+
);
423+
const response = await this.api.fetcher.fetch({
424+
method: "post",
425+
url,
426+
path: `/api/environments/${teamId}/desktop_recordings/${recordingId}/upload_transcript/`,
427+
parameters: {
428+
body: transcript,
429+
},
430+
});
431+
432+
if (!response.ok) {
433+
throw new Error(`Failed to upload transcript: ${response.statusText}`);
434+
}
435+
436+
return await response.json();
437+
}
405438
}

src/main/preload.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,36 @@ contextBridge.exposeInMainWorld("electronAPI", {
202202
ipcRenderer.invoke("notetaker:get-recording", recordingId),
203203
notetakerDeleteRecording: (recordingId: string): Promise<void> =>
204204
ipcRenderer.invoke("notetaker:delete-recording", recordingId),
205+
// Real-time transcript listener
206+
onTranscriptSegment: (
207+
listener: (segment: {
208+
posthog_recording_id: string;
209+
timestamp: number;
210+
speaker: string | null;
211+
text: string;
212+
confidence: number | null;
213+
is_final: boolean;
214+
}) => void,
215+
): (() => void) => {
216+
const channel = "recall:transcript-segment";
217+
const wrapped = (_event: IpcRendererEvent, segment: unknown) =>
218+
listener(segment as never);
219+
ipcRenderer.on(channel, wrapped);
220+
return () => ipcRenderer.removeListener(channel, wrapped);
221+
},
222+
// Meeting ended listener (trigger sync)
223+
onMeetingEnded: (
224+
listener: (event: {
225+
posthog_recording_id: string;
226+
platform: string;
227+
}) => void,
228+
): (() => void) => {
229+
const channel = "recall:meeting-ended";
230+
const wrapped = (_event: IpcRendererEvent, event: unknown) =>
231+
listener(event as never);
232+
ipcRenderer.on(channel, wrapped);
233+
return () => ipcRenderer.removeListener(channel, wrapped);
234+
},
205235
// Shell API
206236
shellCreate: (sessionId: string, cwd?: string): Promise<void> =>
207237
ipcRenderer.invoke("shell:create", sessionId, cwd),

src/main/services/recallRecording.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ export function initializeRecallSDK(
155155
console.log(
156156
`[Recall SDK] Updated recording ${session.recordingId} status to processing`,
157157
);
158+
159+
// Notify renderer that meeting ended
160+
console.log(
161+
`[Recall SDK] Sending meeting-ended event for ${session.recordingId}`,
162+
);
163+
const { BrowserWindow } = await import("electron");
164+
const allWindows = BrowserWindow.getAllWindows();
165+
for (const window of allWindows) {
166+
window.webContents.send("recall:meeting-ended", {
167+
posthog_recording_id: session.recordingId,
168+
platform: session.platform,
169+
});
170+
}
171+
console.log(
172+
`[Recall SDK] Sent meeting-ended to ${allWindows.length} window(s)`,
173+
);
174+
175+
// Clean up session after upload complete
176+
activeSessions.delete(evt.window.id);
158177
} catch (error) {
159178
console.error(
160179
"[Recall SDK] Failed to update recording status:",
@@ -165,16 +184,59 @@ export function initializeRecallSDK(
165184
}
166185
});
167186

168-
RecallAiSdk.addEventListener("meeting-closed", async (evt) => {
187+
RecallAiSdk.addEventListener("meeting-closed", async (_evt) => {
169188
console.log("[Recall SDK] Meeting closed");
170-
activeSessions.delete(evt.window.id);
189+
// Note: Session cleanup is now handled in upload-progress listener
190+
// to ensure we don't delete the session before upload completes
171191
});
172192

173193
RecallAiSdk.addEventListener("meeting-updated", async (_evt) => {});
174194

175195
RecallAiSdk.addEventListener("media-capture-status", async (_evt) => {});
176196

177-
RecallAiSdk.addEventListener("realtime-event", async (_evt) => {});
197+
RecallAiSdk.addEventListener("realtime-event", async (evt) => {
198+
// Handle real-time transcript segments from AssemblyAI
199+
if (evt.event === "transcript.data") {
200+
const session = activeSessions.get(evt.window.id);
201+
if (!session) {
202+
console.warn(
203+
`[Recall SDK] Received transcript for unknown window: ${evt.window.id}`,
204+
);
205+
return;
206+
}
207+
208+
// Parse the words array to build the text
209+
const words = evt.data?.data?.words || [];
210+
if (words.length === 0) {
211+
return; // No words in this segment
212+
}
213+
214+
const text = words.map((w: { text: string }) => w.text).join(" ");
215+
const speaker = evt.data?.data?.participant?.name || null;
216+
const firstWord = words[0];
217+
const timestamp = firstWord?.start_timestamp?.relative
218+
? Math.floor(firstWord.start_timestamp.relative * 1000)
219+
: 0;
220+
221+
console.log(
222+
`[Recall SDK] Transcript segment: "${text}" (speaker: ${speaker})`,
223+
);
224+
225+
// Send transcript segment to renderer via IPC
226+
const { BrowserWindow } = await import("electron");
227+
const allWindows = BrowserWindow.getAllWindows();
228+
for (const window of allWindows) {
229+
window.webContents.send("recall:transcript-segment", {
230+
posthog_recording_id: session.recordingId,
231+
timestamp,
232+
speaker,
233+
text,
234+
confidence: null, // AssemblyAI doesn't provide confidence per segment
235+
is_final: true, // AssemblyAI sends finalized segments
236+
});
237+
}
238+
}
239+
});
178240

179241
RecallAiSdk.addEventListener("error", async (evt) => {
180242
console.error(
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Badge, Box, Card, Flex, ScrollArea, Text } from "@radix-ui/themes";
2+
import { useEffect, useRef, useState } from "react";
3+
import { useLiveTranscript } from "../hooks/useTranscript";
4+
5+
interface LiveTranscriptViewProps {
6+
posthogRecordingId: string; // PostHog UUID
7+
}
8+
9+
export function LiveTranscriptView({
10+
posthogRecordingId,
11+
}: LiveTranscriptViewProps) {
12+
const scrollRef = useRef<HTMLDivElement>(null);
13+
const [autoScroll, setAutoScroll] = useState(true);
14+
15+
const { segments, addSegment, forceUpload, pendingCount } =
16+
useLiveTranscript(posthogRecordingId);
17+
18+
// Listen for new transcript segments from IPC
19+
useEffect(() => {
20+
console.log(
21+
`[LiveTranscript] Setting up listener for recording ${posthogRecordingId}`,
22+
);
23+
24+
const cleanup = window.electronAPI.onTranscriptSegment((segment) => {
25+
console.log(
26+
`[LiveTranscript] Received segment for ${segment.posthog_recording_id}:`,
27+
segment.text,
28+
);
29+
30+
if (segment.posthog_recording_id !== posthogRecordingId) {
31+
console.log(
32+
`[LiveTranscript] Ignoring segment - not for this recording (${posthogRecordingId})`,
33+
);
34+
return; // Not for this recording
35+
}
36+
37+
console.log("[LiveTranscript] Adding segment to local buffer");
38+
39+
// Add to local buffer
40+
addSegment({
41+
timestamp: segment.timestamp,
42+
speaker: segment.speaker,
43+
text: segment.text,
44+
confidence: segment.confidence,
45+
});
46+
});
47+
48+
return cleanup;
49+
}, [posthogRecordingId, addSegment]);
50+
51+
// Listen for meeting-ended event to force upload remaining segments
52+
useEffect(() => {
53+
console.log(
54+
`[LiveTranscript] Setting up meeting-ended listener for ${posthogRecordingId}`,
55+
);
56+
57+
const cleanup = window.electronAPI.onMeetingEnded((event) => {
58+
if (event.posthog_recording_id === posthogRecordingId) {
59+
console.log(`[LiveTranscript] Meeting ended, force uploading segments`);
60+
forceUpload();
61+
}
62+
});
63+
64+
return cleanup;
65+
}, [posthogRecordingId, forceUpload]);
66+
67+
// Auto-scroll to bottom when new segments arrive
68+
useEffect(() => {
69+
if (autoScroll && scrollRef.current) {
70+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
71+
}
72+
}, [autoScroll]);
73+
74+
// Detect manual scroll to disable auto-scroll
75+
const handleScroll = () => {
76+
if (!scrollRef.current) return;
77+
78+
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
79+
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
80+
setAutoScroll(isNearBottom);
81+
};
82+
83+
if (!segments || segments.length === 0) {
84+
return (
85+
<Box p="4">
86+
<Card>
87+
<Text color="gray" size="2">
88+
Waiting for transcript...
89+
</Text>
90+
</Card>
91+
</Box>
92+
);
93+
}
94+
95+
return (
96+
<Box
97+
p="4"
98+
style={{ height: "100%", display: "flex", flexDirection: "column" }}
99+
>
100+
<Flex justify="between" mb="3">
101+
<Text size="2" weight="bold">
102+
Live transcript
103+
</Text>
104+
<Flex gap="2">
105+
{pendingCount > 0 && (
106+
<Badge color="blue" radius="full">
107+
{pendingCount} pending upload
108+
</Badge>
109+
)}
110+
<Badge color="green" radius="full">
111+
{segments.length} segments
112+
</Badge>
113+
</Flex>
114+
</Flex>
115+
116+
<ScrollArea
117+
type="auto"
118+
scrollbars="vertical"
119+
style={{ flex: 1, maxHeight: "60vh" }} // Limited height
120+
ref={scrollRef as never}
121+
onScroll={handleScroll}
122+
>
123+
<Flex direction="column" gap="2">
124+
{segments.map((segment, idx) => (
125+
<Card
126+
key={`${segment.timestamp}-${idx}`}
127+
style={{
128+
opacity: 1,
129+
}}
130+
>
131+
<Flex direction="column" gap="1">
132+
<Flex align="center" gap="2">
133+
{segment.speaker && (
134+
<Text size="1" weight="bold" color="gray">
135+
{segment.speaker}
136+
</Text>
137+
)}
138+
<Text size="1" color="gray">
139+
{formatTimestamp(segment.timestamp)}
140+
</Text>
141+
</Flex>
142+
<Text size="2">{segment.text}</Text>
143+
</Flex>
144+
</Card>
145+
))}
146+
</Flex>
147+
</ScrollArea>
148+
149+
{!autoScroll && (
150+
<Box mt="2">
151+
<Text size="1" color="gray">
152+
Scroll to bottom to enable auto-scroll
153+
</Text>
154+
</Box>
155+
)}
156+
</Box>
157+
);
158+
}
159+
160+
function formatTimestamp(milliseconds: number): string {
161+
const totalSeconds = Math.floor(milliseconds / 1000);
162+
const hours = Math.floor(totalSeconds / 3600);
163+
const minutes = Math.floor((totalSeconds % 3600) / 60);
164+
const seconds = totalSeconds % 60;
165+
166+
if (hours > 0) {
167+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
168+
}
169+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
170+
}

0 commit comments

Comments
 (0)