feat: persist transcripts permanently

This commit is contained in:
2026-01-29 13:21:51 -08:00
parent 43a544a886
commit 9bf92d3365
4 changed files with 457 additions and 42 deletions
+51 -15
View File
@@ -604,7 +604,7 @@ body {
min-height: 0;
}
.recordings-list {
.transcripts-list {
width: 300px;
background-color: var(--surface-color);
border-radius: 0.5rem;
@@ -612,24 +612,27 @@ body {
overflow-y: auto;
}
.recordings-list h3 {
.transcripts-list h3 {
margin-bottom: 1rem;
font-size: 1.125rem;
}
.no-recordings {
.no-transcripts {
color: var(--text-secondary);
font-style: italic;
}
.recordings-items {
.transcript-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recording-item {
.transcript-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
@@ -638,34 +641,67 @@ body {
border: 1px solid var(--border-color);
}
.recording-item:hover {
.transcript-content {
flex: 1;
padding-right: 0.5rem;
}
.transcript-item:hover {
background-color: var(--surface-color);
}
.recording-item.selected {
.transcript-item.selected {
background-color: var(--primary-color);
color: white;
}
.recording-item.selected .recording-time {
.transcript-item.selected .transcript-time {
color: white;
}
.recording-item.selected .recording-status {
.transcript-item.selected .transcript-status {
color: rgba(255, 255, 255, 0.8);
}
.recording-time {
.transcript-time {
font-weight: 500;
font-size: 0.875rem;
}
.recording-status {
.transcript-status {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.transcript-item .delete-button {
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s;
opacity: 0.7;
}
.transcript-item .delete-button:hover {
background-color: var(--danger-color);
border-color: var(--danger-color);
color: white;
opacity: 1;
}
.transcript-item.selected .delete-button {
border-color: rgba(255, 255, 255, 0.5);
color: white;
}
.transcript-item.selected .delete-button:hover {
background-color: var(--danger-color);
border-color: var(--danger-color);
}
.main-content {
flex: 1;
display: flex;
@@ -674,14 +710,14 @@ body {
min-width: 0;
}
.recording-details {
.transcript-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.recording-header {
.transcript-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -689,12 +725,12 @@ body {
gap: 1rem;
}
.recording-header h2 {
.transcript-header h2 {
font-size: 1.25rem;
margin: 0;
}
.recording-actions {
.transcript-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
+141 -27
View File
@@ -14,6 +14,21 @@ interface TranscriptSegment {
speaker: string;
}
interface StoredTranscriptSegment {
start: number;
end: number;
text: string;
speaker: string;
}
interface StoredRecording {
id: string;
timestamp: string; // ISO 8601 string
duration: number;
transcript_segments: StoredTranscriptSegment[];
summary: string | null;
}
interface Recording {
id: string;
timestamp: Date;
@@ -51,6 +66,26 @@ function App() {
initializeApp();
}, []);
// Helper function to convert stored recording to frontend format
const storedToFrontend = (stored: StoredRecording): Recording => ({
id: stored.id,
timestamp: new Date(stored.timestamp),
duration: stored.duration,
transcriptSegments: stored.transcript_segments,
summary: stored.summary,
isGeneratingSummary: false,
summaryProgress: undefined,
});
// Helper function to convert frontend recording to stored format
const frontendToStored = (recording: Recording): StoredRecording => ({
id: recording.id,
timestamp: recording.timestamp.toISOString(),
duration: recording.duration,
transcript_segments: recording.transcriptSegments,
summary: recording.summary,
});
// Cleanup timers and listeners on unmount
useEffect(() => {
return () => {
@@ -128,6 +163,17 @@ function App() {
setAppState("ready");
setStatusMessage("");
setShowLogs(false);
// Load saved recordings
try {
const savedRecordings = await invoke<StoredRecording[]>("load_recordings");
const loadedRecordings = savedRecordings.map(storedToFrontend);
setRecordings(loadedRecordings);
console.log(`Loaded ${loadedRecordings.length} transcripts from storage`);
} catch (loadError) {
console.error("Failed to load transcripts:", loadError);
// Don't fail app init if we can't load transcripts
}
} catch (error) {
console.error("Initialization failed:", error);
setAppState("error");
@@ -278,6 +324,17 @@ function App() {
setRecordings(prev => [finalRecording, ...prev]);
setSelectedRecordingId(finalRecording.id);
setActiveRecording(null);
// Save to persistent storage
try {
await invoke("save_recording", {
recording: frontendToStored(finalRecording)
});
console.log("Transcript saved to storage");
} catch (saveError) {
console.error("Failed to save transcript:", saveError);
// Don't fail the whole operation if storage fails
}
}
// Brief delay to show completion
@@ -312,18 +369,36 @@ function App() {
const summaryResult = await invoke<string>("summarize", { transcript: fullTranscript });
// Update the recording with the summary
setRecordings(prev => prev.map(r =>
r.id === recordingId
? { ...r, summary: summaryResult, isGeneratingSummary: false, summaryProgress: 100 }
: r
));
const updatedRecording = recordings.find(r => r.id === recordingId);
if (updatedRecording) {
const recordingWithSummary = {
...updatedRecording,
summary: summaryResult,
isGeneratingSummary: false,
summaryProgress: 100
};
// Clear progress after a brief delay
setTimeout(() => {
setRecordings(prev => prev.map(r =>
r.id === recordingId ? { ...r, summaryProgress: undefined } : r
r.id === recordingId ? recordingWithSummary : r
));
}, 1000);
// Update in persistent storage
try {
await invoke("update_recording", {
recording: frontendToStored(recordingWithSummary)
});
console.log("Transcript updated with summary");
} catch (updateError) {
console.error("Failed to update transcript:", updateError);
}
// Clear progress after a brief delay
setTimeout(() => {
setRecordings(prev => prev.map(r =>
r.id === recordingId ? { ...r, summaryProgress: undefined } : r
));
}, 1000);
}
} catch (error) {
console.error("Failed to generate summary:", error);
setErrorMessage(String(error));
@@ -344,6 +419,31 @@ function App() {
}
};
const deleteRecording = async (recordingId: string) => {
// Confirm deletion
if (!confirm("Are you sure you want to delete this transcript? This action cannot be undone.")) {
return;
}
try {
// Delete from backend storage
await invoke("delete_recording", { recordingId });
// Remove from state
setRecordings(prev => prev.filter(r => r.id !== recordingId));
// Clear selection if we deleted the selected recording
if (selectedRecordingId === recordingId) {
setSelectedRecordingId(null);
}
console.log(`Transcript ${recordingId} deleted`);
} catch (error) {
console.error("Failed to delete transcript:", error);
alert(`Failed to delete transcript: ${error}`);
}
};
const downloadTranscript = (recordingId: string) => {
const recording = recordings.find(r => r.id === recordingId);
if (!recording) return;
@@ -451,7 +551,7 @@ function App() {
<section className="controls-section">
{appState === "ready" && (
<button className="record-button" onClick={startRecording}>
🎙 Start Recording
🎙 Start Transcribing
</button>
)}
@@ -465,7 +565,7 @@ function App() {
)}
</div>
<button className="stop-button" onClick={stopRecording}>
Stop Recording
Stop Transcribing
</button>
</div>
)}
@@ -483,24 +583,38 @@ function App() {
);
const renderRecordingsList = () => (
<aside className="recordings-list">
<h3>Recording History</h3>
<aside className="transcripts-list">
<h3>Transcript History</h3>
{recordings.length === 0 ? (
<p className="no-recordings">No recordings yet</p>
<p className="no-transcripts">No transcripts yet</p>
) : (
<ul className="recordings-items">
<ul className="transcript-items">
{recordings.map(recording => (
<li
key={recording.id}
className={`recording-item ${selectedRecordingId === recording.id ? 'selected' : ''}`}
onClick={() => setSelectedRecordingId(recording.id)}
className={`transcript-item ${selectedRecordingId === recording.id ? 'selected' : ''}`}
>
<div className="recording-time">
{recording.timestamp.toLocaleTimeString()} - {formatDuration(recording.duration)}
</div>
<div className="recording-status">
{recording.summary ? '✓ Summary' : recording.isGeneratingSummary ? '⏳ Summarizing...' : ''}
<div
className="transcript-content"
onClick={() => setSelectedRecordingId(recording.id)}
>
<div className="transcript-time">
{recording.timestamp.toLocaleTimeString()} - {formatDuration(recording.duration)}
</div>
<div className="transcript-status">
{recording.summary ? '✓ Summary' : recording.isGeneratingSummary ? '⏳ Summarizing...' : ''}
</div>
</div>
<button
className="delete-button"
onClick={(e) => {
e.stopPropagation();
deleteRecording(recording.id);
}}
title="Delete transcript"
>
🗑
</button>
</li>
))}
</ul>
@@ -538,10 +652,10 @@ function App() {
{/* Display selected recording or active recording */}
{displayedRecording && (
<div className="recording-details">
<div className="recording-header">
<h2>Recording from {displayedRecording.timestamp.toLocaleString()}</h2>
<div className="recording-actions">
<div className="transcript-details">
<div className="transcript-header">
<h2>Transcript from {displayedRecording.timestamp.toLocaleString()}</h2>
<div className="transcript-actions">
<button
className="secondary-button"
onClick={() => downloadTranscript(displayedRecording.id)}
@@ -588,7 +702,7 @@ function App() {
{!displayedRecording && recordings.length === 0 && appState === "ready" && (
<div className="empty-state">
<p>Click "Start Recording" to begin your first meeting transcription!</p>
<p>Click "Start Transcribing" to begin transcribing your first meeting!</p>
</div>
)}
</div>