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 /makima/frontend/src/components | |
| 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>
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx | 339 |
1 files changed, 339 insertions, 0 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> + ); +} |
