Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,10 @@ export class PostHogAPIClient {
text: string;
confidence: number | null;
}>;
extracted_tasks?: Array<{
title: string;
description: string;
}>;
},
) {
this.validateRecordingId(recordingId);
Expand Down
5 changes: 5 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("notetaker:get-recording", recordingId),
notetakerDeleteRecording: (recordingId: string): Promise<void> =>
ipcRenderer.invoke("notetaker:delete-recording", recordingId),
notetakerExtractTasks: (
transcriptText: string,
openaiApiKey: string,
): Promise<Array<{ title: string; description: string }>> =>
ipcRenderer.invoke("notetaker:extract-tasks", transcriptText, openaiApiKey),
// Real-time transcript listener
onTranscriptSegment: (
listener: (segment: {
Expand Down
56 changes: 56 additions & 0 deletions src/main/services/recallRecording.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createOpenAI } from "@ai-sdk/openai";
import { PostHogAPIClient } from "@api/posthogClient";
import RecallAiSdk from "@recallai/desktop-sdk";
import { generateObject } from "ai";
import { ipcMain } from "electron";
import { z } from "zod";
import { TASK_EXTRACTION_PROMPT } from "./transcription-prompts";

interface RecordingSession {
windowId: string;
Expand Down Expand Up @@ -428,6 +432,45 @@ export function getActiveSessions() {
return Array.from(activeSessions.values());
}

async function extractTasksFromTranscript(
transcriptText: string,
openaiApiKey: string,
): Promise<Array<{ title: string; description: string }>> {
try {
const openai = createOpenAI({ apiKey: openaiApiKey });

const schema = z.object({
tasks: z.array(
z.object({
title: z.string().describe("Brief task title"),
description: z.string().describe("Detailed description with context"),
}),
),
});

const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema,
messages: [
{
role: "system",
content:
"You are a helpful assistant that extracts actionable tasks from conversation transcripts. Be generous in identifying work items - include feature requests, requirements, and any work that needs to be done.",
},
{
role: "user",
content: `${TASK_EXTRACTION_PROMPT}\n${transcriptText}`,
},
],
});

return object.tasks || [];
} catch (error) {
console.error("[Task Extraction] Error:", error);
throw error;
}
}

export function registerRecallIPCHandlers() {
ipcMain.handle(
"recall:initialize",
Expand Down Expand Up @@ -483,4 +526,17 @@ export function registerRecallIPCHandlers() {
}
return await posthogClient.deleteDesktopRecording(recordingId);
});

ipcMain.handle(
"notetaker:extract-tasks",
async (_event, transcriptText, openaiApiKey) => {
console.log("[Task Extraction] Starting task extraction...");
const tasks = await extractTasksFromTranscript(
transcriptText,
openaiApiKey,
);
console.log(`[Task Extraction] Extracted ${tasks.length} tasks`);
return tasks;
},
);
}
128 changes: 123 additions & 5 deletions src/renderer/features/notetaker/components/LiveTranscriptView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { Badge, Box, Card, Flex, ScrollArea, Text } from "@radix-ui/themes";
import { ListChecksIcon, SparkleIcon } from "@phosphor-icons/react";
import {
Badge,
Box,
Button,
Card,
Flex,
Heading,
ScrollArea,
Separator,
Text,
} from "@radix-ui/themes";
import { useEffect, useRef, useState } from "react";
import { useLiveTranscript } from "../hooks/useTranscript";
import { useAuthStore } from "../../../stores/authStore";
import { useExtractTasks } from "../hooks/useExtractTasks";
import { useLiveTranscript, useTranscript } from "../hooks/useTranscript";

interface LiveTranscriptViewProps {
posthogRecordingId: string; // PostHog UUID
Expand All @@ -11,10 +24,22 @@ export function LiveTranscriptView({
}: LiveTranscriptViewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const openaiApiKey = useAuthStore((state) => state.openaiApiKey);

const { segments, addSegment, forceUpload, pendingCount } =
useLiveTranscript(posthogRecordingId);

const { data: transcriptData } = useTranscript(posthogRecordingId);
const extractTasksMutation = useExtractTasks();

const handleExtractTasks = () => {
const fullText = segments.map((s) => s.text).join(" ");
extractTasksMutation.mutate({
recordingId: posthogRecordingId,
transcriptText: fullText,
});
};

// Auto-scroll to bottom when new segments arrive
useEffect(() => {
if (autoScroll && scrollRef.current && segments.length > 0) {
Expand Down Expand Up @@ -55,21 +80,41 @@ export function LiveTranscriptView({
return cleanup;
}, [posthogRecordingId, addSegment]);

// Listen for meeting-ended event to force upload remaining segments
// Listen for meeting-ended event to force upload remaining segments and extract tasks
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`);
console.log(
`[LiveTranscript] Meeting ended, force uploading segments and extracting tasks`,
);
forceUpload();

// Automatically extract tasks when meeting ends (if OpenAI key is configured)
if (openaiApiKey && segments.length > 0) {
console.log(
`[LiveTranscript] Auto-extracting tasks for ${segments.length} segments`,
);
const fullText = segments.map((s) => s.text).join(" ");
extractTasksMutation.mutate({
recordingId: posthogRecordingId,
transcriptText: fullText,
});
}
}
});

return cleanup;
}, [posthogRecordingId, forceUpload]);
}, [
posthogRecordingId,
forceUpload,
openaiApiKey,
segments,
extractTasksMutation,
]);

// Detect manual scroll to disable auto-scroll
const handleScroll = () => {
Expand Down Expand Up @@ -177,6 +222,79 @@ export function LiveTranscriptView({
</Text>
</Box>
)}

{/* Extract Tasks Section */}
{segments.length > 0 && (
<>
<Separator size="4" my="4" />
<Flex direction="column" gap="3">
<Flex justify="between" align="center">
<Heading size="4">
<Flex align="center" gap="2">
<ListChecksIcon size={20} />
Extracted tasks
</Flex>
</Heading>
<Button
size="2"
onClick={handleExtractTasks}
disabled={extractTasksMutation.isPending || !openaiApiKey}
>
<SparkleIcon />
{extractTasksMutation.isPending
? "Extracting..."
: "Extract tasks"}
</Button>
</Flex>

{!openaiApiKey && !transcriptData?.extracted_tasks && (
<Card>
<Text size="2" color="gray">
Add OpenAI API key in settings to extract tasks
</Text>
</Card>
)}

{transcriptData?.extracted_tasks &&
transcriptData.extracted_tasks.length > 0 && (
<Flex direction="column" gap="2">
{transcriptData.extracted_tasks.map((task, idx) => (
<Card key={`${task.title}-${idx}`}>
<Flex direction="column" gap="1">
<Text size="2" weight="medium">
{task.title}
</Text>
<Text size="1" color="gray">
{task.description}
</Text>
</Flex>
</Card>
))}
</Flex>
)}

{transcriptData?.extracted_tasks &&
transcriptData.extracted_tasks.length === 0 && (
<Card>
<Text size="2" color="gray">
No tasks found in transcript
</Text>
</Card>
)}

{extractTasksMutation.isError && (
<Card>
<Text size="2" color="red">
Failed to extract tasks:{" "}
{extractTasksMutation.error instanceof Error
? extractTasksMutation.error.message
: "Unknown error"}
</Text>
</Card>
)}
</Flex>
</>
)}
</Box>
);
}
Expand Down
55 changes: 55 additions & 0 deletions src/renderer/features/notetaker/hooks/useExtractTasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "../../../stores/authStore";

export interface ExtractedTask {
title: string;
description: string;
}

export function useExtractTasks() {
const openaiApiKey = useAuthStore((state) => state.openaiApiKey);
const { client } = useAuthStore();
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({
recordingId,
transcriptText,
}: {
recordingId: string;
transcriptText: string;
}) => {
if (!openaiApiKey) {
throw new Error("OpenAI API key not configured");
}

if (!client) {
throw new Error("Not authenticated");
}

// Extract tasks using AI
const tasks = await window.electronAPI.notetakerExtractTasks(
transcriptText,
openaiApiKey,
);

// Upload tasks to backend
await client.uploadDesktopRecordingTranscript(recordingId, {
full_text: transcriptText,
segments: [], // Segments already uploaded
extracted_tasks: tasks,
});

return { tasks, recordingId };
},
onSuccess: (data) => {
// Invalidate queries to refetch with new tasks from backend
queryClient.invalidateQueries({
queryKey: ["notetaker-recording", data.recordingId],
});
queryClient.invalidateQueries({
queryKey: ["notetaker-transcript", data.recordingId],
});
},
});
}
4 changes: 4 additions & 0 deletions src/renderer/features/notetaker/hooks/useTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface TranscriptSegment {
interface TranscriptData {
full_text: string;
segments: TranscriptSegment[];
extracted_tasks?: Array<{
title: string;
description: string;
}>;
}

export function useTranscript(recordingId: string | null) {
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ export interface IElectronAPI {
recall_recording_id: string | null;
}>;
notetakerDeleteRecording: (recordingId: string) => Promise<void>;
notetakerExtractTasks: (
transcriptText: string,
openaiApiKey: string,
) => Promise<Array<{ title: string; description: string }>>;
// Real-time transcript listener
onTranscriptSegment: (
listener: (segment: {
Expand Down
Loading