From b8035a7bc86dfb40af66f80e0564a41b8c6f7ba8 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 15 Jan 2026 00:05:20 +0000 Subject: feat(listen): add transcript analysis UI panel Add UI integration for the transcript analysis feature: - Add TranscriptSaved WebSocket message type to notify client when transcript is saved - Create TranscriptAnalysisPanel component to display analysis results - Shows requirements grouped by category, decisions, action items with priorities - Displays speaker statistics and suggested contract name/description - Provides buttons to create new contract or add to existing contract - Update Listen page to show analysis panel as modal overlay after recording stops - Update useWebSocket hook to handle transcriptSaved message Co-Authored-By: Claude Opus 4.5 --- .../components/listen/TranscriptAnalysisPanel.tsx | 339 +++++++++++++++++++++ makima/frontend/src/hooks/useWebSocket.ts | 13 +- makima/frontend/src/lib/listenApi.ts | 168 ++++++++++ makima/frontend/src/routes/listen.tsx | 41 +++ makima/frontend/src/types/messages.ts | 9 +- 5 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx create mode 100644 makima/frontend/src/lib/listenApi.ts (limited to 'makima/frontend') diff --git a/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx b/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx new file mode 100644 index 0000000..89d56a8 --- /dev/null +++ b/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx @@ -0,0 +1,339 @@ +import { useState, useCallback } from "react"; +import { + analyzeTranscript, + createContractFromTranscript, + updateContractFromTranscript, + type TranscriptAnalysisResult, + type CreateContractResponse, +} from "../../lib/listenApi"; + +interface TranscriptAnalysisPanelProps { + fileId: string; + contractId: string; + selectedContractId: string | null; + onContractCreated?: (response: CreateContractResponse) => void; + onContractUpdated?: (response: CreateContractResponse) => void; + onClose?: () => void; +} + +type AnalysisState = "idle" | "analyzing" | "analyzed" | "creating" | "updating"; + +export function TranscriptAnalysisPanel({ + fileId, + contractId, + selectedContractId, + onContractCreated, + onContractUpdated, + onClose, +}: TranscriptAnalysisPanelProps) { + const [state, setState] = useState("idle"); + const [analysis, setAnalysis] = useState(null); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const handleAnalyze = useCallback(async () => { + setState("analyzing"); + setError(null); + setSuccessMessage(null); + + try { + const response = await analyzeTranscript(fileId); + setAnalysis(response.analysis); + setState("analyzed"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to analyze transcript"); + setState("idle"); + } + }, [fileId]); + + const handleCreateContract = useCallback(async () => { + if (!analysis) return; + + setState("creating"); + setError(null); + + try { + const response = await createContractFromTranscript(fileId, { + name: analysis.suggestedContractName, + description: analysis.suggestedDescription, + includeRequirements: true, + includeDecisions: true, + includeActionItems: true, + }); + setSuccessMessage(`Created contract "${response.contractName}"`); + onContractCreated?.(response); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create contract"); + setState("analyzed"); + } + }, [fileId, analysis, onContractCreated]); + + const handleUpdateContract = useCallback(async () => { + if (!analysis || !selectedContractId) return; + + setState("updating"); + setError(null); + + try { + const response = await updateContractFromTranscript(fileId, selectedContractId, { + description: analysis.suggestedDescription, + includeRequirements: true, + includeDecisions: true, + includeActionItems: true, + }); + setSuccessMessage(`Updated contract "${response.contractName}"`); + onContractUpdated?.(response); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update contract"); + setState("analyzed"); + } + }, [fileId, analysis, selectedContractId, onContractUpdated]); + + // Group requirements by category + const groupedRequirements = analysis?.requirements.reduce((acc, req) => { + const category = req.category || "General"; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(req); + return acc; + }, {} as Record) || {}; + + return ( +
+ {/* Header */} +
+
+ TRANSCRIPT ANALYSIS// +
+ {onClose && ( + + )} +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Success message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Initial state - Show analyze button */} + {state === "idle" && ( +
+

+ Transcript saved. Analyze to extract requirements, decisions, and action items. +

+ +
+ )} + + {/* Analyzing state */} + {state === "analyzing" && ( +
+
+

Analyzing transcript...

+
+ )} + + {/* Analysis results */} + {(state === "analyzed" || state === "creating" || state === "updating") && analysis && ( +
+ {/* Suggested Contract Info */} + {(analysis.suggestedContractName || analysis.suggestedDescription) && ( +
+
+ Suggested Contract +
+ {analysis.suggestedContractName && ( +
+ {analysis.suggestedContractName} +
+ )} + {analysis.suggestedDescription && ( +
+ {analysis.suggestedDescription} +
+ )} +
+ )} + + {/* Requirements */} + {Object.keys(groupedRequirements).length > 0 && ( +
+
+ Requirements ({analysis.requirements.length}) +
+ {Object.entries(groupedRequirements).map(([category, reqs]) => ( +
+
+ {category} +
+
    + {reqs.map((req, idx) => ( +
  • + - + {req.text} + {req.speaker && ( + ({req.speaker}) + )} +
  • + ))} +
+
+ ))} +
+ )} + + {/* Decisions */} + {analysis.decisions.length > 0 && ( +
+
+ Decisions ({analysis.decisions.length}) +
+
    + {analysis.decisions.map((decision, idx) => ( +
  • +
    + - + {decision.text} +
    + {decision.context && ( +
    + Context: {decision.context} +
    + )} +
  • + ))} +
+
+ )} + + {/* Action Items */} + {analysis.actionItems.length > 0 && ( +
+
+ Action Items ({analysis.actionItems.length}) +
+
    + {analysis.actionItems.map((item, idx) => ( +
  • +
    + - +
    + {item.text} +
    + {item.assignee && ( + + @{item.assignee} + + )} + {item.priority && ( + + {item.priority} + + )} +
    +
    +
    +
  • + ))} +
+
+ )} + + {/* Key Topics */} + {analysis.keyTopics.length > 0 && ( +
+
+ Key Topics +
+
+ {analysis.keyTopics.map((topic, idx) => ( + + {topic} + + ))} +
+
+ )} + + {/* Speaker Statistics */} + {analysis.speakerSummary.length > 0 && ( +
+
+ Speaker Statistics +
+
+ {analysis.speakerSummary.map((speaker, idx) => ( +
+ + {speaker.speaker} + +
+
+
+ + {speaker.contributionPercentage.toFixed(0)}% + +
+ ))} +
+
+ )} + + {/* Action Buttons */} +
+ + {selectedContractId && selectedContractId !== contractId && ( + + )} +
+
+ )} +
+ ); +} diff --git a/makima/frontend/src/hooks/useWebSocket.ts b/makima/frontend/src/hooks/useWebSocket.ts index c593621..8a8616d 100644 --- a/makima/frontend/src/hooks/useWebSocket.ts +++ b/makima/frontend/src/hooks/useWebSocket.ts @@ -24,10 +24,11 @@ interface UseWebSocketOptions { onTranscript?: (transcript: TranscriptEntry) => void; onError?: (code: string, message: string) => void; onStopped?: (reason: string) => void; + onTranscriptSaved?: (fileId: string, contractId: string) => void; } export function useWebSocket(options: UseWebSocketOptions = {}) { - const { onReady, onTranscript, onError, onStopped } = options; + const { onReady, onTranscript, onError, onStopped, onTranscriptSaved } = options; const [state, setState] = useState({ status: "disconnected", @@ -42,10 +43,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { const pendingDisconnectRef = useRef(false); // Store callbacks in refs to avoid recreating handlers - const callbacksRef = useRef({ onReady, onTranscript, onError, onStopped }); + const callbacksRef = useRef({ onReady, onTranscript, onError, onStopped, onTranscriptSaved }); useEffect(() => { - callbacksRef.current = { onReady, onTranscript, onError, onStopped }; - }, [onReady, onTranscript, onError, onStopped]); + callbacksRef.current = { onReady, onTranscript, onError, onStopped, onTranscriptSaved }; + }, [onReady, onTranscript, onError, onStopped, onTranscriptSaved]); const connect = useCallback((): Promise => { return new Promise((resolve) => { @@ -138,6 +139,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}) { } } break; + + case "transcriptSaved": + callbacksRef.current.onTranscriptSaved?.(message.fileId, message.contractId); + break; } } catch { console.error("Failed to parse WebSocket message:", event.data); diff --git a/makima/frontend/src/lib/listenApi.ts b/makima/frontend/src/lib/listenApi.ts new file mode 100644 index 0000000..187ebe0 --- /dev/null +++ b/makima/frontend/src/lib/listenApi.ts @@ -0,0 +1,168 @@ +import { API_BASE } from './api'; +import { supabase } from './supabase'; + +// ============================================================================= +// Authentication helper (same pattern as api.ts) +// ============================================================================= + +/** Storage key for API key */ +const API_KEY_STORAGE_KEY = "makima_api_key"; + +/** Get stored API key from localStorage */ +function getStoredApiKey(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(API_KEY_STORAGE_KEY); +} + +/** Get auth headers for API requests */ +async function getAuthHeaders(): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + // Try Supabase session first + if (supabase) { + const { data: { session } } = await supabase.auth.getSession(); + if (session?.access_token) { + headers["Authorization"] = `Bearer ${session.access_token}`; + return headers; + } + } + + // Fall back to API key if available + const apiKey = getStoredApiKey(); + if (apiKey) { + headers["X-Makima-API-Key"] = apiKey; + } + + return headers; +} + +// ============================================================================= +// Transcript Analysis Types +// ============================================================================= + +export interface TranscriptAnalysisResult { + requirements: Array<{ + text: string; + speaker: string; + timestamp: number; + confidence: number; + category?: string; + }>; + decisions: Array<{ + text: string; + speaker: string; + timestamp: number; + confidence: number; + context?: string; + }>; + actionItems: Array<{ + text: string; + speaker: string; + timestamp: number; + assignee?: string; + priority?: string; + }>; + keyTopics: string[]; + suggestedContractName?: string; + suggestedDescription?: string; + speakerSummary: Array<{ + speaker: string; + wordCount: number; + speakingTimeSeconds: number; + contributionPercentage: number; + }>; +} + +export interface AnalyzeResponse { + fileId: string; + analysis: TranscriptAnalysisResult; +} + +export interface CreateContractResponse { + contractId: string; + contractName: string; + filesCreated: Array<{ id: string; name: string; fileType: string }>; + tasksCreated: Array<{ id: string; name: string }>; +} + +export interface CreateContractOptions { + name?: string; + description?: string; + includeRequirements?: boolean; + includeDecisions?: boolean; + includeActionItems?: boolean; +} + +// ============================================================================= +// Listen API Functions +// ============================================================================= + +/** + * Analyze a transcript file to extract requirements, decisions, and action items. + */ +export async function analyzeTranscript(fileId: string): Promise { + const response = await fetch(`${API_BASE}/api/v1/listen/analyze`, { + method: 'POST', + headers: await getAuthHeaders(), + body: JSON.stringify({ fileId }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Failed to analyze transcript' })); + throw new Error(error.message || 'Failed to analyze transcript'); + } + + return response.json(); +} + +/** + * Create a contract from a transcript analysis. + */ +export async function createContractFromTranscript( + fileId: string, + options?: CreateContractOptions +): Promise { + const response = await fetch(`${API_BASE}/api/v1/listen/create-contract`, { + method: 'POST', + headers: await getAuthHeaders(), + body: JSON.stringify({ + fileId, + ...options, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Failed to create contract' })); + throw new Error(error.message || 'Failed to create contract'); + } + + return response.json(); +} + +/** + * Update an existing contract with transcript analysis. + */ +export async function updateContractFromTranscript( + fileId: string, + contractId: string, + options?: Omit +): Promise { + const response = await fetch(`${API_BASE}/api/v1/listen/update-contract`, { + method: 'POST', + headers: await getAuthHeaders(), + body: JSON.stringify({ + fileId, + contractId, + ...options, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Failed to update contract' })); + throw new Error(error.message || 'Failed to update contract'); + } + + return response.json(); +} diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index 36c468b..55cf7e6 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -3,6 +3,7 @@ import { Masthead } from "../components/Masthead"; import { SpeakerPanel } from "../components/listen/SpeakerPanel"; import { TranscriptPanel } from "../components/listen/TranscriptPanel"; import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; +import { TranscriptAnalysisPanel } from "../components/listen/TranscriptAnalysisPanel"; import { useMicrophone } from "../hooks/useMicrophone"; import { useWebSocket } from "../hooks/useWebSocket"; import { listContracts } from "../lib/api"; @@ -20,6 +21,12 @@ export default function ListenPage() { const [contractsLoading, setContractsLoading] = useState(true); const { session, isAuthenticated } = useAuth(); + // Saved transcript state for analysis + const [savedTranscript, setSavedTranscript] = useState<{ + fileId: string; + contractId: string; + } | null>(null); + // Fetch contracts on mount useEffect(() => { if (!isAuthenticated) { @@ -61,6 +68,10 @@ export default function ListenPage() { setIsListening(false); setActiveSpeaker(null); }, + onTranscriptSaved: (fileId, contractId) => { + // Store the saved transcript info for analysis + setSavedTranscript({ fileId, contractId }); + }, }); const wsRef = useRef(ws); @@ -157,8 +168,13 @@ export default function ListenPage() { ws.disconnect(); setIsListening(false); setActiveSpeaker(null); + setSavedTranscript(null); }, [mic, ws]); + const handleCloseAnalysis = useCallback(() => { + setSavedTranscript(null); + }, []); + const error = ws.error || mic.error; return ( @@ -194,6 +210,31 @@ export default function ListenPage() { />
+ + {/* Transcript Analysis Panel - shown after recording stops and transcript is saved */} + {savedTranscript && !isListening && ( +
+
+ { + // Refresh contracts list and select the new contract + setContracts((prev) => [ + { id: response.contractId, name: response.contractName }, + ...prev, + ]); + setSelectedContractId(response.contractId); + }} + onContractUpdated={() => { + // Keep the current selection + }} + onClose={handleCloseAnalysis} + /> +
+
+ )}
); } diff --git a/makima/frontend/src/types/messages.ts b/makima/frontend/src/types/messages.ts index 070cdfb..c227f73 100644 --- a/makima/frontend/src/types/messages.ts +++ b/makima/frontend/src/types/messages.ts @@ -39,11 +39,18 @@ export type StoppedMessage = { reason: string; }; +export type TranscriptSavedMessage = { + type: "transcriptSaved"; + fileId: string; + contractId: string; +}; + export type ServerMessage = | ReadyMessage | TranscriptMessage | ErrorMessage - | StoppedMessage; + | StoppedMessage + | TranscriptSavedMessage; // Transcript entry for display export interface TranscriptEntry { -- cgit v1.2.3