diff --git a/src/api/posthogClient.ts b/src/api/posthogClient.ts index 48eb0938..d68611fc 100644 --- a/src/api/posthogClient.ts +++ b/src/api/posthogClient.ts @@ -393,6 +393,8 @@ export class PostHogAPIClient { is_final: boolean; }>; full_text?: string; + summary?: string; + extracted_tasks?: Array<{ title: string; description: string }>; }, ) { this.validateRecordingId(recordingId); diff --git a/src/renderer/features/notetaker/components/RecordingView.tsx b/src/renderer/features/notetaker/components/RecordingView.tsx index 5dcf0676..32b152b3 100644 --- a/src/renderer/features/notetaker/components/RecordingView.tsx +++ b/src/renderer/features/notetaker/components/RecordingView.tsx @@ -1,6 +1,10 @@ -import { Badge, Box, Card, Flex, Text } from "@radix-ui/themes"; +import { Badge, Box, Button, Card, Flex, Text } from "@radix-ui/themes"; import type { RecordingItem } from "@renderer/features/notetaker/hooks/useAllRecordings"; -import type { TranscriptSegment } from "@renderer/stores/activeRecordingStore"; +import { analyzeRecording } from "@renderer/services/recordingService"; +import type { + AnalysisStatus, + TranscriptSegment, +} from "@renderer/stores/activeRecordingStore"; import { useEffect, useRef, useState } from "react"; interface RecordingViewProps { @@ -58,6 +62,42 @@ export function RecordingView({ recordingItem }: RecordingViewProps) { ), ]; + const analysisStatus: AnalysisStatus | undefined = + recordingItem.type === "active" + ? recordingItem.recording.analysisStatus + : undefined; + + const summary = + recordingItem.type === "active" + ? recordingItem.recording.summary + : recordingItem.recording.transcript?.summary; + + const extractedTasks = + recordingItem.type === "active" + ? recordingItem.recording.extractedTasks + : (recordingItem.recording.transcript?.extracted_tasks as + | Array<{ + title: string; + description: string; + }> + | undefined); + + const analysisError = + recordingItem.type === "active" + ? recordingItem.recording.analysisError + : undefined; + + const [isAnalyzing, setIsAnalyzing] = useState(false); + + const handleAnalyze = async () => { + setIsAnalyzing(true); + try { + await analyzeRecording(recording.id); + } finally { + setIsAnalyzing(false); + } + }; + return ( - - - Coming soon - - + {summary ? ( + {summary} + ) : ( + + {analysisStatus === "analyzing_summary" ? ( + + Analyzing... + + ) : analysisStatus === "error" ? ( + <> + + {analysisError || "Analysis failed"} + + + + ) : analysisStatus === "skipped" ? ( + <> + + Configure OpenAI API key to analyze + + + + ) : ( + + )} + + )} )} @@ -123,11 +209,72 @@ export function RecordingView({ recordingItem }: RecordingViewProps) { Action items - - - Coming soon - - + {extractedTasks && extractedTasks.length > 0 ? ( + + {extractedTasks.map((task) => ( + + + {task.title} + + + {task.description} + + + ))} + + ) : ( + + {analysisStatus === "analyzing_tasks" ? ( + + Extracting tasks... + + ) : analysisStatus === "error" ? ( + <> + + {analysisError || "Analysis failed"} + + + + ) : analysisStatus === "skipped" ? ( + <> + + Configure OpenAI API key to analyze + + + + ) : analysisStatus === "completed" ? ( + + No tasks found + + ) : ( + + )} + + )} )} diff --git a/src/renderer/services/aiAnalysisService.ts b/src/renderer/services/aiAnalysisService.ts new file mode 100644 index 00000000..6897e6ff --- /dev/null +++ b/src/renderer/services/aiAnalysisService.ts @@ -0,0 +1,91 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import type { ExtractedTask } from "@renderer/stores/activeRecordingStore"; +import { generateObject } from "ai"; +import { z } from "zod"; + +const SUMMARY_PROMPT = `Create a very brief (3-7 words) title that summarizes what this conversation is about. + +Transcript:`; + +const TASK_EXTRACTION_PROMPT = `Analyze the following conversation transcript and extract any actionable tasks, feature requests, bug fixes, or work items that were discussed or requested. This includes: +- Explicit action items ("we need to...", "let's build...") +- Feature requests ("I want...", "please build...") +- Bug reports ("this is broken...", "fix the...") +- Requirements ("it should have...", "make it...") + +For each task, provide a clear title and a description with relevant context from the conversation. + +If there are no actionable tasks, return an empty tasks array. + +Transcript:`; + +export async function generateTranscriptSummary( + transcriptText: string, + openaiApiKey: string, +): Promise { + try { + const openai = createOpenAI({ apiKey: openaiApiKey }); + + const { object } = await generateObject({ + model: openai("gpt-4o-mini"), + schema: z.object({ + title: z.string().describe("A brief 3-7 word summary title"), + }), + messages: [ + { + role: "system", + content: + "You are a helpful assistant that creates concise titles for conversation transcripts. The title should be 3-7 words and capture the main topic.", + }, + { + role: "user", + content: `${SUMMARY_PROMPT}\n${transcriptText}`, + }, + ], + }); + + return object.title || null; + } catch (error) { + console.error("[AI Analysis] Failed to generate summary:", error); + throw error; + } +} + +export async function extractTasksFromTranscript( + transcriptText: string, + openaiApiKey: string, +): Promise { + 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("[AI Analysis] Failed to extract tasks:", error); + throw error; + } +} diff --git a/src/renderer/services/recordingService.ts b/src/renderer/services/recordingService.ts index dbb94879..c6ba6947 100644 --- a/src/renderer/services/recordingService.ts +++ b/src/renderer/services/recordingService.ts @@ -1,3 +1,7 @@ +import { + extractTasksFromTranscript, + generateTranscriptSummary, +} from "@renderer/services/aiAnalysisService"; import { useActiveRecordingStore } from "@renderer/stores/activeRecordingStore"; import { useAuthStore } from "@/renderer/features/auth/stores/authStore"; @@ -129,6 +133,8 @@ export function initializeRecordingService() { ); } } + + analyzeRecording(data.posthog_recording_id); } store.updateStatus(data.posthog_recording_id, "uploading"); @@ -211,6 +217,77 @@ async function uploadPendingSegments(recordingId: string): Promise { } } +export async function analyzeRecording(recordingId: string): Promise { + const store = useActiveRecordingStore.getState(); + const authStore = useAuthStore.getState(); + + const recording = store.getRecording(recordingId); + if (!recording) { + console.warn(`[AI Analysis] Recording ${recordingId} not found`); + return; + } + + const openaiApiKey = authStore.openaiApiKey; + if (!openaiApiKey) { + console.log("[AI Analysis] No OpenAI API key, skipping analysis"); + store.setAnalysisStatus(recordingId, "skipped"); + return; + } + + if (recording.segments.length === 0) { + console.log("[AI Analysis] No transcript segments, skipping analysis"); + store.setAnalysisStatus(recordingId, "skipped"); + return; + } + + const fullTranscript = recording.segments.map((s) => s.text).join(" "); + + try { + store.setAnalysisStatus(recordingId, "analyzing_summary"); + console.log("[AI Analysis] Generating summary..."); + + const summary = await generateTranscriptSummary( + fullTranscript, + openaiApiKey, + ); + if (summary) { + store.setSummary(recordingId, summary); + } + + store.setAnalysisStatus(recordingId, "analyzing_tasks"); + console.log("[AI Analysis] Extracting tasks..."); + + const tasks = await extractTasksFromTranscript( + fullTranscript, + openaiApiKey, + ); + store.setExtractedTasks(recordingId, tasks); + + store.setAnalysisStatus(recordingId, "completed"); + console.log( + `[AI Analysis] Complete - summary: "${summary}", tasks: ${tasks.length}`, + ); + + const client = authStore.client; + if (client && (summary || tasks.length > 0)) { + try { + await client.updateDesktopRecordingTranscript(recordingId, { + summary: summary || undefined, + extracted_tasks: tasks.length > 0 ? tasks : undefined, + }); + console.log("[AI Analysis] Updated backend with analysis results"); + } catch (error) { + console.error("[AI Analysis] Failed to update backend:", error); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Analysis failed"; + console.error("[AI Analysis] Error:", errorMessage); + store.setAnalysisError(recordingId, errorMessage); + } +} + /** * Handle crash recovery - upload any pending segments and clear from IDB * diff --git a/src/renderer/stores/activeRecordingStore.ts b/src/renderer/stores/activeRecordingStore.ts index 300aa74a..1fa6b485 100644 --- a/src/renderer/stores/activeRecordingStore.ts +++ b/src/renderer/stores/activeRecordingStore.ts @@ -27,6 +27,21 @@ export interface TranscriptSegment { is_final: boolean; } +// AI analysis status +export type AnalysisStatus = + | "pending" // Not started yet + | "analyzing_summary" // Generating summary + | "analyzing_tasks" // Extracting tasks + | "completed" // All analysis done + | "error" // Analysis failed + | "skipped"; // Skipped (no OpenAI key) + +// AI-extracted task +export interface ExtractedTask { + title: string; + description: string; +} + // Active recording state - extends backend DesktopRecording with client-only fields export interface ActiveRecording extends Schemas.DesktopRecording { // Client-only fields for real-time state @@ -35,6 +50,12 @@ export interface ActiveRecording extends Schemas.DesktopRecording { uploadRetries: number; // Retry tracking errorMessage?: string; // Error details lastUploadedSegmentIndex: number; // Track which segments have been uploaded + + // AI analysis fields + analysisStatus: AnalysisStatus; + summary?: string; // AI-generated 3-7 word title + extractedTasks?: ExtractedTask[]; // AI-extracted action items + analysisError?: string; // Error message if analysis failed } interface ActiveRecordingState { @@ -53,6 +74,12 @@ interface ActiveRecordingState { clearRecording: (recordingId: string) => void; getRecording: (recordingId: string) => ActiveRecording | undefined; getPendingSegments: (recordingId: string) => TranscriptSegment[]; + + // AI analysis methods + setAnalysisStatus: (recordingId: string, status: AnalysisStatus) => void; + setSummary: (recordingId: string, summary: string) => void; + setExtractedTasks: (recordingId: string, tasks: ExtractedTask[]) => void; + setAnalysisError: (recordingId: string, error: string) => void; } // Custom storage adapter for idb-keyval @@ -88,12 +115,12 @@ export const useActiveRecordingStore = create()( activeRecordings: [ ...state.activeRecordings, { - ...recording, // Spread all DesktopRecording fields - // Add client-only fields + ...recording, segments: [], notes: "", uploadRetries: 0, lastUploadedSegmentIndex: -1, + analysisStatus: "pending", }, ], })); @@ -226,9 +253,70 @@ export const useActiveRecordingStore = create()( ); if (!recording) return []; - // Return segments that haven't been uploaded yet return recording.segments.slice(recording.lastUploadedSegmentIndex + 1); }, + + setAnalysisStatus: (recordingId, status) => { + if (!isValidRecordingId(recordingId)) { + console.error( + `[ActiveRecording] Invalid recording ID: ${recordingId}`, + ); + return; + } + set((state) => ({ + activeRecordings: state.activeRecordings.map((r) => + r.id === recordingId ? { ...r, analysisStatus: status } : r, + ), + })); + console.log(`[ActiveRecording] Analysis status: ${status}`); + }, + + setSummary: (recordingId, summary) => { + if (!isValidRecordingId(recordingId)) { + console.error( + `[ActiveRecording] Invalid recording ID: ${recordingId}`, + ); + return; + } + set((state) => ({ + activeRecordings: state.activeRecordings.map((r) => + r.id === recordingId ? { ...r, summary } : r, + ), + })); + console.log(`[ActiveRecording] Summary set: "${summary}"`); + }, + + setExtractedTasks: (recordingId, tasks) => { + if (!isValidRecordingId(recordingId)) { + console.error( + `[ActiveRecording] Invalid recording ID: ${recordingId}`, + ); + return; + } + set((state) => ({ + activeRecordings: state.activeRecordings.map((r) => + r.id === recordingId ? { ...r, extractedTasks: tasks } : r, + ), + })); + console.log(`[ActiveRecording] ${tasks.length} tasks extracted`); + }, + + setAnalysisError: (recordingId, error) => { + if (!isValidRecordingId(recordingId)) { + console.error( + `[ActiveRecording] Invalid recording ID: ${recordingId}`, + ); + return; + } + set((state) => ({ + activeRecordings: state.activeRecordings.map((r) => + r.id === recordingId + ? { ...r, analysisStatus: "error", analysisError: error } + : r, + ), + })); + console.error(`[ActiveRecording] Analysis error: ${error}`); + }, }), { name: "active-recordings", // IDB key