summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-15 00:05:20 +0000
committersoryu <soryu@soryu.co>2026-01-15 01:30:02 +0000
commitb8035a7bc86dfb40af66f80e0564a41b8c6f7ba8 (patch)
tree59223bc7b3ec88c5ced42ed77f419e9fc4501941
parenteae8e698e89d7e5c8dc5bcdb2dcef61f25295515 (diff)
downloadsoryu-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.tsx339
-rw-r--r--makima/frontend/src/hooks/useWebSocket.ts13
-rw-r--r--makima/frontend/src/lib/listenApi.ts168
-rw-r--r--makima/frontend/src/routes/listen.tsx41
-rw-r--r--makima/frontend/src/types/messages.ts9
-rw-r--r--makima/src/server/handlers/listen.rs10
-rw-r--r--makima/src/server/messages.rs7
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