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
2 changes: 2 additions & 0 deletions src/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
171 changes: 159 additions & 12 deletions src/renderer/features/notetaker/components/RecordingView.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -58,6 +62,42 @@
),
];

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

Check failure on line 95 in src/renderer/features/notetaker/components/RecordingView.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Cannot find name 'recording'.
} finally {
setIsAnalyzing(false);
}
};

return (
<Box
p="4"
Expand Down Expand Up @@ -107,11 +147,57 @@
Summary
</Text>
<Card>
<Flex align="center" justify="center" py="4">
<Text size="2" color="gray">
Coming soon
</Text>
</Flex>
{summary ? (
<Text size="2">{summary}</Text>
) : (
<Flex
direction="column"
align="center"
justify="center"
py="4"
gap="2"
>
{analysisStatus === "analyzing_summary" ? (
<Text size="2" color="gray">
Analyzing...
</Text>
) : analysisStatus === "error" ? (
<>
<Text size="2" color="red">
{analysisError || "Analysis failed"}
</Text>
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Retry
</Button>
</>
) : analysisStatus === "skipped" ? (
<>
<Text size="2" color="gray">
Configure OpenAI API key to analyze
</Text>
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Analyze now
</Button>
</>
) : (
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Analyze with AI
</Button>
)}
</Flex>
)}
</Card>
</Flex>
)}
Expand All @@ -123,11 +209,72 @@
Action items
</Text>
<Card>
<Flex align="center" justify="center" py="4">
<Text size="2" color="gray">
Coming soon
</Text>
</Flex>
{extractedTasks && extractedTasks.length > 0 ? (
<Flex direction="column" gap="2">
{extractedTasks.map((task) => (
<Box key={task.title}>
<Text size="2" weight="bold">
{task.title}
</Text>
<Text size="1" color="gray">
{task.description}
</Text>
</Box>
))}
</Flex>
) : (
<Flex
direction="column"
align="center"
justify="center"
py="4"
gap="2"
>
{analysisStatus === "analyzing_tasks" ? (
<Text size="2" color="gray">
Extracting tasks...
</Text>
) : analysisStatus === "error" ? (
<>
<Text size="2" color="red">
{analysisError || "Analysis failed"}
</Text>
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Retry
</Button>
</>
) : analysisStatus === "skipped" ? (
<>
<Text size="2" color="gray">
Configure OpenAI API key to analyze
</Text>
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Analyze now
</Button>
</>
) : analysisStatus === "completed" ? (
<Text size="2" color="gray">
No tasks found
</Text>
) : (
<Button
size="1"
onClick={handleAnalyze}
disabled={isAnalyzing}
>
Analyze with AI
</Button>
)}
</Flex>
)}
</Card>
</Flex>
)}
Expand Down
91 changes: 91 additions & 0 deletions src/renderer/services/aiAnalysisService.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<ExtractedTask[]> {
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;
}
}
77 changes: 77 additions & 0 deletions src/renderer/services/recordingService.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -129,6 +133,8 @@ export function initializeRecordingService() {
);
}
}

analyzeRecording(data.posthog_recording_id);
}

store.updateStatus(data.posthog_recording_id, "uploading");
Expand Down Expand Up @@ -211,6 +217,77 @@ async function uploadPendingSegments(recordingId: string): Promise<void> {
}
}

export async function analyzeRecording(recordingId: string): Promise<void> {
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
*
Expand Down
Loading
Loading