diff options
| author | soryu <soryu@soryu.co> | 2026-01-15 00:05:20 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 01:30:02 +0000 |
| commit | b8035a7bc86dfb40af66f80e0564a41b8c6f7ba8 (patch) | |
| tree | 59223bc7b3ec88c5ced42ed77f419e9fc4501941 | |
| parent | eae8e698e89d7e5c8dc5bcdb2dcef61f25295515 (diff) | |
| download | soryu-b8035a7bc86dfb40af66f80e0564a41b8c6f7ba8.tar.gz soryu-b8035a7bc86dfb40af66f80e0564a41b8c6f7ba8.zip | |
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 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx | 339 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useWebSocket.ts | 13 | ||||
| -rw-r--r-- | makima/frontend/src/lib/listenApi.ts | 168 | ||||
| -rw-r--r-- | makima/frontend/src/routes/listen.tsx | 41 | ||||
| -rw-r--r-- | makima/frontend/src/types/messages.ts | 9 | ||||
| -rw-r--r-- | makima/src/server/handlers/listen.rs | 10 | ||||
| -rw-r--r-- | makima/src/server/messages.rs | 7 |
7 files changed, 582 insertions, 5 deletions
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<AnalysisState>("idle"); + const [analysis, setAnalysis] = useState<TranscriptAnalysisResult | null>(null); + const [error, setError] = useState<string | null>(null); + const [successMessage, setSuccessMessage] = useState<string | null>(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<string, typeof analysis.requirements>) || {}; + + return ( + <div className="panel p-4 flex flex-col gap-4"> + {/* Header */} + <div className="flex justify-between items-center border-b border-dashed border-[rgba(117,170,252,0.35)] pb-2"> + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + TRANSCRIPT ANALYSIS// + </div> + {onClose && ( + <button + onClick={onClose} + className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + [X] + </button> + )} + </div> + + {/* Error display */} + {error && ( + <div className="font-mono text-xs text-red-400 px-2 py-2 border border-red-400/50 bg-red-400/10"> + {error} + </div> + )} + + {/* Success message */} + {successMessage && ( + <div className="font-mono text-xs text-green-400 px-2 py-2 border border-green-400/50 bg-green-400/10"> + {successMessage} + </div> + )} + + {/* Initial state - Show analyze button */} + {state === "idle" && ( + <div className="flex flex-col items-center gap-3 py-4"> + <p className="font-mono text-sm text-[#9bc3ff] text-center"> + Transcript saved. Analyze to extract requirements, decisions, and action items. + </p> + <button + onClick={handleAnalyze} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase tracking-wide" + > + Analyze Transcript + </button> + </div> + )} + + {/* Analyzing state */} + {state === "analyzing" && ( + <div className="flex flex-col items-center gap-3 py-8"> + <div className="w-6 h-6 border-2 border-[#3f6fb3] border-t-[#75aafc] rounded-full animate-spin" /> + <p className="font-mono text-sm text-[#9bc3ff]">Analyzing transcript...</p> + </div> + )} + + {/* Analysis results */} + {(state === "analyzed" || state === "creating" || state === "updating") && analysis && ( + <div className="flex flex-col gap-4 overflow-y-auto max-h-[60vh]"> + {/* Suggested Contract Info */} + {(analysis.suggestedContractName || analysis.suggestedDescription) && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Suggested Contract + </div> + {analysis.suggestedContractName && ( + <div className="font-mono text-sm text-[#dbe7ff] mb-1"> + {analysis.suggestedContractName} + </div> + )} + {analysis.suggestedDescription && ( + <div className="font-mono text-xs text-[#9bc3ff]"> + {analysis.suggestedDescription} + </div> + )} + </div> + )} + + {/* Requirements */} + {Object.keys(groupedRequirements).length > 0 && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Requirements ({analysis.requirements.length}) + </div> + {Object.entries(groupedRequirements).map(([category, reqs]) => ( + <div key={category} className="mb-3 last:mb-0"> + <div className="font-mono text-[10px] text-[#3f6fb3] uppercase tracking-wider mb-1"> + {category} + </div> + <ul className="space-y-1"> + {reqs.map((req, idx) => ( + <li key={idx} className="font-mono text-xs text-[#dbe7ff] flex gap-2"> + <span className="text-[#3f6fb3]">-</span> + <span>{req.text}</span> + {req.speaker && ( + <span className="text-[#9bc3ff] text-[10px]">({req.speaker})</span> + )} + </li> + ))} + </ul> + </div> + ))} + </div> + )} + + {/* Decisions */} + {analysis.decisions.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Decisions ({analysis.decisions.length}) + </div> + <ul className="space-y-2"> + {analysis.decisions.map((decision, idx) => ( + <li key={idx} className="font-mono text-xs text-[#dbe7ff]"> + <div className="flex gap-2"> + <span className="text-[#3f6fb3]">-</span> + <span>{decision.text}</span> + </div> + {decision.context && ( + <div className="ml-4 text-[10px] text-[#9bc3ff] mt-1"> + Context: {decision.context} + </div> + )} + </li> + ))} + </ul> + </div> + )} + + {/* Action Items */} + {analysis.actionItems.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Action Items ({analysis.actionItems.length}) + </div> + <ul className="space-y-2"> + {analysis.actionItems.map((item, idx) => ( + <li key={idx} className="font-mono text-xs text-[#dbe7ff]"> + <div className="flex gap-2 items-start"> + <span className="text-[#3f6fb3]">-</span> + <div className="flex-1"> + <span>{item.text}</span> + <div className="flex gap-2 mt-1"> + {item.assignee && ( + <span className="text-[10px] px-1.5 py-0.5 bg-[#0f1c2f] border border-[#3f6fb3] text-[#9bc3ff]"> + @{item.assignee} + </span> + )} + {item.priority && ( + <span + className={`text-[10px] px-1.5 py-0.5 border ${ + item.priority === "high" + ? "border-red-400/50 text-red-400 bg-red-400/10" + : item.priority === "medium" + ? "border-yellow-400/50 text-yellow-400 bg-yellow-400/10" + : "border-[#3f6fb3] text-[#9bc3ff] bg-[#0f1c2f]" + }`} + > + {item.priority} + </span> + )} + </div> + </div> + </div> + </li> + ))} + </ul> + </div> + )} + + {/* Key Topics */} + {analysis.keyTopics.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Key Topics + </div> + <div className="flex flex-wrap gap-2"> + {analysis.keyTopics.map((topic, idx) => ( + <span + key={idx} + className="font-mono text-[10px] px-2 py-1 bg-[#0f1c2f] border border-[#3f6fb3] text-[#9bc3ff]" + > + {topic} + </span> + ))} + </div> + </div> + )} + + {/* Speaker Statistics */} + {analysis.speakerSummary.length > 0 && ( + <div className="border border-[rgba(117,170,252,0.25)] p-3"> + <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> + Speaker Statistics + </div> + <div className="space-y-2"> + {analysis.speakerSummary.map((speaker, idx) => ( + <div key={idx} className="flex items-center gap-3"> + <span className="font-mono text-xs text-[#dbe7ff] min-w-[100px]"> + {speaker.speaker} + </span> + <div className="flex-1 h-2 bg-[#0f1c2f] overflow-hidden"> + <div + className="h-full bg-[#3f6fb3]" + style={{ width: `${speaker.contributionPercentage}%` }} + /> + </div> + <span className="font-mono text-[10px] text-[#9bc3ff] min-w-[40px] text-right"> + {speaker.contributionPercentage.toFixed(0)}% + </span> + </div> + ))} + </div> + </div> + )} + + {/* Action Buttons */} + <div className="flex gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.35)]"> + <button + onClick={handleCreateContract} + disabled={state === "creating" || state === "updating"} + className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed" + > + {state === "creating" ? "Creating..." : "Create New Contract"} + </button> + {selectedContractId && selectedContractId !== contractId && ( + <button + onClick={handleUpdateContract} + disabled={state === "creating" || state === "updating"} + className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed" + > + {state === "updating" ? "Updating..." : "Add to Current Contract"} + </button> + )} + </div> + </div> + )} + </div> + ); +} 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<WebSocketState>({ 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<boolean> => { 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<HeadersInit> { + 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<AnalyzeResponse> { + 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<CreateContractResponse> { + 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<CreateContractOptions, 'name'> +): Promise<CreateContractResponse> { + 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() { /> </div> </main> + + {/* Transcript Analysis Panel - shown after recording stops and transcript is saved */} + {savedTranscript && !isListening && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden"> + <TranscriptAnalysisPanel + fileId={savedTranscript.fileId} + contractId={savedTranscript.contractId} + selectedContractId={selectedContractId} + onContractCreated={(response) => { + // 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} + /> + </div> + </div> + )} </div> ); } 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 { diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs index 524c48a..e1bc30e 100644 --- a/makima/src/server/handlers/listen.rs +++ b/makima/src/server/handlers/listen.rs @@ -553,6 +553,16 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { deduplicated_count = final_entries.len(), "Saved final transcript to file" ); + + // Send TranscriptSaved message to client + if let Some(contract_id) = target_contract_id { + let _ = response_tx + .send(ServerMessage::TranscriptSaved { + file_id: fid.to_string(), + contract_id: contract_id.to_string(), + }) + .await; + } } Err(e) => { tracing::error!( diff --git a/makima/src/server/messages.rs b/makima/src/server/messages.rs index 401afb0..9c50334 100644 --- a/makima/src/server/messages.rs +++ b/makima/src/server/messages.rs @@ -73,6 +73,13 @@ pub enum ServerMessage { Ready { session_id: String }, /// Transcription result Transcript(TranscriptMessage), + /// Transcript has been saved to a file + TranscriptSaved { + /// The ID of the file where the transcript was saved + file_id: String, + /// The ID of the contract the file belongs to + contract_id: String, + }, /// Error occurred during processing Error { code: String, message: String }, /// Session has been stopped |
