Skip to content

Commit 1cd4c33

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 ab0ce3b commit 1cd4c33

File tree

8 files changed

+596
-130
lines changed

8 files changed

+596
-130
lines changed

src/api/posthogClient.ts

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

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

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),
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)