From 4d26cca64523799ea19cc5c700c7e9853a2bb0e4 Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 26 Jan 2026 14:15:23 +0000 Subject: [PATCH] fix(transcription): Behebt Start- und API-Fehler in der App [2f488f42] --- .dev_session/SESSION_INFO | 2 +- NOTION_TASK_SUMMARY.md | 31 +- TRANSCRIPTION_TOOL.md | 4 +- start-gemini.sh | 33 +- transcription-tool/backend/app.py | 22 ++ transcription-tool/backend/prompt_library.py | 35 +++ .../backend/services/insights_service.py | 23 +- .../backend/services/llm_service.py | 91 ++++++ .../backend/services/translation_service.py | 64 ++++ transcription-tool/frontend/src/App.tsx | 284 +++++++++++------- 10 files changed, 427 insertions(+), 162 deletions(-) create mode 100644 transcription-tool/backend/services/llm_service.py create mode 100644 transcription-tool/backend/services/translation_service.py diff --git a/.dev_session/SESSION_INFO b/.dev_session/SESSION_INFO index 6b4e958e..39300e3e 100644 --- a/.dev_session/SESSION_INFO +++ b/.dev_session/SESSION_INFO @@ -1 +1 @@ -{"task_id": "2f388f42-8544-805f-8822-d1765c7d354f", "token": "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"} \ No newline at end of file +{"task_id": "2f488f42-8544-819a-8407-f29748b3e0b8", "token": "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"} \ No newline at end of file diff --git a/NOTION_TASK_SUMMARY.md b/NOTION_TASK_SUMMARY.md index 26b37520..377a9f34 100644 --- a/NOTION_TASK_SUMMARY.md +++ b/NOTION_TASK_SUMMARY.md @@ -1,25 +1,14 @@ -# Zusammenfassung: Task [2f388f42] - Promptdatabase integrieren +**Task Summary: Add a Share Button `[2f488f42]`** -Der Task wurde erfolgreich abgeschlossen. Das "Meeting Assistant (Transcription Tool)" verfügt nun über eine "AI Insights"-Funktion, mit der Benutzer auf Knopfdruck Meeting-Protokolle, Aufgabenlisten und rollenbasierte Zusammenfassungen aus Transkripten generieren können. +**Status:** ✅ Done -## Implementierte Features +**Project:** Meeting Assistant (Transcription Tool) -1. **Prompt-Datenbank (`prompt_library.py`):** Eine zentrale Bibliothek für KI-Anweisungen wurde erstellt, um verschiedene Analyse-Typen (Protokoll, Action Items, Sales Summary) zu steuern. -2. **Insights Service (`insights_service.py`):** Eine neue Backend-Logik wurde entwickelt, die das Transkript aus der Datenbank abruft, formatiert und zusammen mit dem passenden Prompt an die Gemini 2.0 Flash API sendet. -3. **Neuer API-Endpunkt:** Der Endpunkt `POST /api/meetings/{id}/insights` wurde implementiert, um die Analyse-Generierung anzustoßen. -4. **Frontend-Integration:** Die Benutzeroberfläche wurde um ein "AI Insights"-Panel erweitert. Benutzer können über Buttons die verschiedenen Analysen anfordern. Die Ergebnisse werden in einem Modal-Fenster angezeigt und können in die Zwischenablage kopiert werden. -5. **Caching:** Die generierten Ergebnisse werden in der Datenbank gespeichert. Um eine Neugenerierung zu Testzwecken zu ermöglichen, wird ein bereits vorhandenes Ergebnis gelöscht und neu erstellt, wenn die Analyse erneut angefordert wird. +**Changes Implemented:** +- A new "Share" button has been successfully added to the toolbar in the transcript detail view. +- The button is visually aligned with the existing "Copy" and "Download" actions, utilizing the `Share2` icon for consistency. +- The feature is UI-only as per the requirements; clicking the button currently triggers a placeholder alert. No backend or sharing logic has been implemented yet. -## Debugging-Prozess & Gelöste Probleme - -Die Implementierung erforderte einen intensiven Debugging-Prozess, um mehrere aufeinanderfolgende Fehler zu beheben: - -1. **`ModuleNotFoundError`:** Ein anfänglicher Importfehler wurde behoben, indem eine eigenständige `gemini_client.py` für den Microservice erstellt wurde. -2. **Fehlender API-Schlüssel:** Der Dienst wurde so angepasst, dass er den API-Schlüssel aus einer Umgebungsvariable liest, die über `docker-compose.yml` bereitgestellt wird. -3. **Falsche API-Initialisierung:** Ein Programmierfehler bei der Verwendung der Gemini-Bibliothek wurde korrigiert. -4. **Falscher Modellname:** Der API-Aufruf wurde auf das korrekte, im Projekt etablierte `gemini-2.0-flash`-Modell umgestellt. -5. **Fehlerhafte Transkript-Formatierung:** Das Kernproblem, das zu leeren KI-Antworten führte, wurde durch die korrekte Interpretation der `absolute_seconds` aus der Datenbank und eine robustere Formatierungslogik gelöst. - -## Ergebnis - -Das Feature ist nun voll funktionsfähig und liefert korrekte, inhaltsbasierte Analysen. Der Code wurde bereinigt, dokumentiert und die Änderungen wurden committed. +**Next Steps:** +- The functionality for sharing can be implemented in a future task. +- The change is ready to be committed and pushed. \ No newline at end of file diff --git a/TRANSCRIPTION_TOOL.md b/TRANSCRIPTION_TOOL.md index 58b02a40..d3265c2b 100644 --- a/TRANSCRIPTION_TOOL.md +++ b/TRANSCRIPTION_TOOL.md @@ -19,7 +19,8 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B ## 2. Key Features (v0.6.0) -### 🚀 **NEU:** AI Insights auf Knopfdruck +### 🚀 **NEU:** AI Insights & Translation +* **Übersetzung (DE/EN):** Übersetzt das gesamte Transkript mit einem Klick ins Englische. * **Meeting-Protokoll:** Erstellt automatisch ein formelles Protokoll (Meeting Minutes) mit Agenda, Entscheidungen und nächsten Schritten. * **Action Items:** Extrahiert eine Aufgabenliste mit Verantwortlichen und Fälligkeiten direkt aus dem Gespräch. * **Rollenbasierte Zusammenfassungen:** Generiert spezifische Zusammenfassungen, z.B. eine "Sales Summary", die sich auf Kundenbedürfnisse, Kaufsignale und nächste Schritte für das Vertriebsteam konzentriert. @@ -51,6 +52,7 @@ Der **Meeting Assistant** ist eine leistungsstarke Suite zur Transkription und B | `GET` | `/meetings` | Liste aller Meetings. | | `POST` | `/upload` | Audio-Upload & Prozess-Start. | | `POST` | `/meetings/{id}/insights` | **Neu:** Generiert eine Analyse (z.B. Protokoll, Action Items). | +| `POST` | `/meetings/{id}/translate` | **Neu:** Übersetzt das Transkript in eine Zielsprache (aktuell: 'English'). | | `POST` | `/meetings/{id}/rename_speaker` | Globale Umbenennung in der DB. | | `PUT` | `/chunks/{id}` | Speichert manuelle Text-Korrekturen. | | `DELETE` | `/meetings/{id}` | Vollständiges Löschen. | diff --git a/start-gemini.sh b/start-gemini.sh index cbd423a0..25de14f6 100644 --- a/start-gemini.sh +++ b/start-gemini.sh @@ -1,15 +1,25 @@ #!/bin/bash # --- Konfiguration --- -# Der Name des finalen Containers, in dem die Gemini CLI läuft. +IMAGE_NAME="gemini-dev-env" CONTAINER_NAME="gemini-session" # --- Aufräumen --- -# Sicherstellen, dass der Config-Ordner existiert, damit Docker ihn als Ordner und nicht als Datei mountet +# Sicherstellen, dass der Config-Ordner existiert mkdir -p .gemini-config +# Prüfen, ob das Docker-Image existiert und es bei Bedarf bauen +if ! docker image inspect "$IMAGE_NAME" &> /dev/null; +then + echo "Docker-Image '$IMAGE_NAME' nicht gefunden. Baue es jetzt aus 'gemini.Dockerfile'..." + docker build -t "$IMAGE_NAME" -f gemini.Dockerfile . + if [ $? -ne 0 ]; then + echo "FEHLER: Docker-Image konnte nicht gebaut werden." + exit 1 + fi +fi + echo "Räume alte Docker-Container auf, falls vorhanden..." -# Entfernt den Haupt-Container und den temporären Container vom letzten Lauf docker rm -f "$CONTAINER_NAME" > /dev/null 2>&1 docker rm -f "${CONTAINER_NAME}-temp" > /dev/null 2>&1 @@ -20,15 +30,15 @@ echo "Starte interaktive Entwicklungs-Session-Konfiguration..." TEMP_FILE=$(mktemp) # Führe dev_session.py in einem temporären, interaktiven Container aus. -# Die Ausgabe wird gleichzeitig im Terminal angezeigt (damit der User die Fragen sieht) -# und in die temporäre Datei geschrieben. +# Die Ausgabe (stdout & stderr) wird gleichzeitig im Terminal angezeigt und in die temporäre Datei geschrieben. docker run -it --rm \ - --env-file .env \ -v "$(pwd):/app" \ + -v "$(pwd)/.gemini-config:/root/.config" \ + -w /app \ --name "${CONTAINER_NAME}-temp" \ - gemini-dev-env python3 dev_session.py | tee "$TEMP_FILE" + "$IMAGE_NAME" python3 dev_session.py 2>&1 | tee "$TEMP_FILE" -# Überprüfe, ob der vorherige Befehl erfolgreich war (exit code 0) +# Überprüfe, ob der Docker-Befehl erfolgreich war (exit code 0) if [ ${PIPESTATUS[0]} -ne 0 ]; then echo "------------------------------------------------------------------" echo "❌ Fehler: Der Konfigurationsprozess wurde abgebrochen oder ist fehlgeschlagen." @@ -39,8 +49,7 @@ if [ ${PIPESTATUS[0]} -ne 0 ]; then fi # Extrahiere den reinen CLI-Kontext aus der temporären Datei -# sed sucht nach den Markern und gibt nur den Text dazwischen aus -CLI_CONTEXT=$(sed -n '/---GEMINI_CLI_CONTEXT_START---/,/---GEMINI_CLI_CONTEXT_END---/p' "$TEMP_FILE" | sed '1d;$d') +CLI_CONTEXT=$(sed -n '/---GEMINI_CLI_CONTEXT_START---/,/---GEMINI_CLI_CONTEXT_END---/{//!p}' "$TEMP_FILE") # Lösche die temporäre Datei rm "$TEMP_FILE" @@ -66,8 +75,8 @@ $CLI_CONTEXT" # Starte den finalen Container mit der Gemini CLI # --prompt-interactive übergibt den initialen Kontext docker run -it --rm \ - --env-file .env \ -v "$(pwd):/app" \ -v "$(pwd)/.gemini-config:/root/.config" \ + -w /app \ --name "$CONTAINER_NAME" \ - gemini-dev-env --prompt-interactive "$FULL_CONTEXT" \ No newline at end of file + "$IMAGE_NAME" gemini --prompt-interactive "$FULL_CONTEXT" diff --git a/transcription-tool/backend/app.py b/transcription-tool/backend/app.py index dd462d3d..162231fa 100644 --- a/transcription-tool/backend/app.py +++ b/transcription-tool/backend/app.py @@ -121,6 +121,28 @@ def create_insight(meeting_id: int, payload: InsightRequest, db: Session = Depen print(f"ERROR: Unexpected error in create_insight: {e}") raise HTTPException(status_code=500, detail="An internal error occurred while generating the insight.") +class TranslationRequest(BaseModel): + target_language: str + +@app.post("/api/meetings/{meeting_id}/translate") +def translate_meeting_transcript(meeting_id: int, payload: TranslationRequest, db: Session = Depends(get_db)): + """ + Triggers the translation of a meeting's transcript. + """ + try: + # For now, we only support English + if payload.target_language.lower() != 'english': + raise HTTPException(status_code=400, detail="Currently, only translation to English is supported.") + + from .services.translation_service import translate_transcript + translation = translate_transcript(db, meeting_id, payload.target_language) + return translation + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"ERROR: Unexpected error in translate_meeting_transcript: {e}") + raise HTTPException(status_code=500, detail="An internal error occurred during translation.") + class RenameRequest(BaseModel): old_name: str new_name: str diff --git a/transcription-tool/backend/prompt_library.py b/transcription-tool/backend/prompt_library.py index 41afc8c3..835c72c5 100644 --- a/transcription-tool/backend/prompt_library.py +++ b/transcription-tool/backend/prompt_library.py @@ -111,3 +111,38 @@ Please provide the output in Markdown format. # You can add more prompts here for other analysis types. # For example, a prompt for a technical summary, a marketing summary, etc. + +TRANSLATE_TRANSCRIPT_PROMPT = """ +You are a highly accurate and fluent translator. +Your task is to translate the given meeting transcript into {target_language}. +Maintain the original format (who said what) as closely as possible. + +**Transcript:** +--- +{transcript} +--- + +**Output:** +Provide only the translated text. Do not add any commentary or additional formatting. +""" + +_PROMPTS = { + "meeting_minutes": MEETING_MINUTES_PROMPT, + "action_items": ACTION_ITEMS_PROMPT, + "sales_summary": SALES_SUMMARY_PROMPT, + "translate_transcript": TRANSLATE_TRANSCRIPT_PROMPT, +} + +def get_prompt(prompt_type: str, context: dict = None) -> str: + """ + Retrieves a prompt by its type and formats it with the given context. + """ + prompt_template = _PROMPTS.get(prompt_type) + if not prompt_template: + raise ValueError(f"Unknown prompt type: {prompt_type}") + + if context: + return prompt_template.format(**context) + + return prompt_template + diff --git a/transcription-tool/backend/services/insights_service.py b/transcription-tool/backend/services/insights_service.py index 811a9657..28236d8e 100644 --- a/transcription-tool/backend/services/insights_service.py +++ b/transcription-tool/backend/services/insights_service.py @@ -2,13 +2,9 @@ import sys import os from sqlalchemy.orm import Session from .. import database -from .. import prompt_library - +from ..prompt_library import get_prompt import logging -from sqlalchemy.orm import Session -from .. import database -from .. import prompt_library -from ..lib.gemini_client import call_gemini_flash +from .llm_service import call_gemini_api logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -57,18 +53,7 @@ def _format_transcript(chunks: list[database.TranscriptChunk]) -> str: return "\n".join(full_transcript) -def get_prompt_by_type(insight_type: str) -> str: - """ - Returns the corresponding prompt from the prompt_library based on the type. - """ - if insight_type == "meeting_minutes": - return prompt_library.MEETING_MINUTES_PROMPT - elif insight_type == "action_items": - return prompt_library.ACTION_ITEMS_PROMPT - elif insight_type == "sales_summary": - return prompt_library.SALES_SUMMARY_PROMPT - else: - raise ValueError(f"Unknown insight type: {insight_type}") + def generate_insight(db: Session, meeting_id: int, insight_type: str) -> database.AnalysisResult: """ @@ -102,7 +87,7 @@ def generate_insight(db: Session, meeting_id: int, insight_type: str) -> databas # This can happen if all chunks are empty or malformed raise ValueError(f"Formatted transcript for meeting {meeting_id} is empty or could not be processed.") - prompt_template = get_prompt_by_type(insight_type) + prompt_template = get_prompt(insight_type) final_prompt = prompt_template.format(transcript_text=transcript_text) # 4. Call the AI model diff --git a/transcription-tool/backend/services/llm_service.py b/transcription-tool/backend/services/llm_service.py new file mode 100644 index 00000000..2ea72ebd --- /dev/null +++ b/transcription-tool/backend/services/llm_service.py @@ -0,0 +1,91 @@ +import os +import requests +import logging +import time + +# Configure logging +logging.basicConfig(level=logging.INFO) + +def call_gemini_api(prompt: str, retries: int = 3, timeout: int = 600) -> str: + """ + Calls the Gemini Pro API with a given prompt. + + Args: + prompt: The text prompt to send to the API. + retries: The number of times to retry on failure. + timeout: The request timeout in seconds. + + Returns: + The text response from the API or an empty string if the response is malformed. + + Raises: + Exception: If the API call fails after all retries. + """ + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + logging.error("GEMINI_API_KEY environment variable not set.") + raise ValueError("API key not found.") + + url = f"https://generativelanguage.googleapis.com/v1/models/gemini-1.5-flash:generateContent?key={api_key}" + headers = {'Content-Type': 'application/json'} + payload = { + "contents": [{ + "parts": [{"text": prompt}] + }], + "generationConfig": { + "temperature": 0.7, + "topK": 40, + "topP": 0.95, + "maxOutputTokens": 8192, + } + } + + for attempt in range(retries): + try: + response = requests.post(url, headers=headers, json=payload, timeout=timeout) + response.raise_for_status() + + result = response.json() + + if 'candidates' in result and result['candidates']: + candidate = result['candidates'][0] + if 'content' in candidate and 'parts' in candidate['content']: + # Check for safety ratings + if 'safetyRatings' in candidate: + blocked = any(r.get('blocked') for r in candidate['safetyRatings']) + if blocked: + logging.error(f"API call blocked due to safety ratings: {candidate['safetyRatings']}") + # Provide a more specific error or return a specific string + return "[Blocked by Safety Filter]" + return candidate['content']['parts'][0]['text'] + + # Handle cases where the response is valid but doesn't contain expected content + if 'promptFeedback' in result and result['promptFeedback'].get('blockReason'): + reason = result['promptFeedback']['blockReason'] + logging.error(f"Prompt was blocked by the API. Reason: {reason}") + return f"[Prompt Blocked: {reason}]" + + logging.warning(f"Unexpected API response structure on attempt {attempt+1}: {result}") + return "" + + except requests.exceptions.HTTPError as e: + if e.response.status_code in [500, 502, 503, 504] and attempt < retries - 1: + wait_time = (2 ** attempt) * 2 # Exponential backoff + logging.warning(f"Server Error {e.response.status_code}. Retrying in {wait_time}s...") + time.sleep(wait_time) + continue + logging.error(f"HTTP Error calling Gemini API: {e.response.status_code} {e.response.text}") + raise + except requests.exceptions.RequestException as e: + if attempt < retries - 1: + wait_time = (2 ** attempt) * 2 + logging.warning(f"Connection Error: {e}. Retrying in {wait_time}s...") + time.sleep(wait_time) + continue + logging.error(f"Final Connection Error calling Gemini API: {e}") + raise + except Exception as e: + logging.error(f"An unexpected error occurred: {e}", exc_info=True) + raise + + return "" # Should not be reached if retries are exhausted diff --git a/transcription-tool/backend/services/translation_service.py b/transcription-tool/backend/services/translation_service.py new file mode 100644 index 00000000..b859a769 --- /dev/null +++ b/transcription-tool/backend/services/translation_service.py @@ -0,0 +1,64 @@ +from sqlalchemy.orm import Session +from ..database import Meeting, AnalysisResult +from .llm_service import call_gemini_api +from ..prompt_library import get_prompt +from typing import Dict, Any, List + +def _format_transcript_for_translation(chunks: List[Any]) -> str: + """Formats the transcript into a single string for the translation prompt.""" + full_transcript = [] + # Ensure chunks are treated correctly, whether they are dicts or objects + for chunk in chunks: + json_content = getattr(chunk, 'json_content', None) + if not json_content: + continue + for line in json_content: + speaker = line.get("speaker", "Unknown") + text = line.get("text", "") + full_transcript.append(f"{speaker}: {text}") + return "\n".join(full_transcript) + +def translate_transcript(db: Session, meeting_id: int, target_language: str) -> AnalysisResult: + """ + Translates the transcript of a meeting and stores it. + """ + meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first() + if not meeting: + raise ValueError("Meeting not found") + + prompt_key = f"translation_{target_language.lower()}" + + # Check if translation already exists + existing_translation = db.query(AnalysisResult).filter( + AnalysisResult.meeting_id == meeting_id, + AnalysisResult.prompt_key == prompt_key + ).first() + + if existing_translation: + return existing_translation + + # Prepare transcript + transcript_text = _format_transcript_for_translation(meeting.chunks) + if not transcript_text: + raise ValueError("Transcript is empty, cannot translate.") + + # Get prompt from library + prompt = get_prompt('translate_transcript', { + 'transcript': transcript_text, + 'target_language': target_language + }) + + # Call Gemini API using the new service function + translated_text = call_gemini_api(prompt) + + # Store result + translation_result = AnalysisResult( + meeting_id=meeting_id, + prompt_key=prompt_key, + result_text=translated_text + ) + db.add(translation_result) + db.commit() + db.refresh(translation_result) + + return translation_result diff --git a/transcription-tool/frontend/src/App.tsx b/transcription-tool/frontend/src/App.tsx index 32fcbd5f..863de20f 100644 --- a/transcription-tool/frontend/src/App.tsx +++ b/transcription-tool/frontend/src/App.tsx @@ -84,6 +84,10 @@ export default function App() { const [insightResult, setInsightResult] = useState('') const [insightTitle, setInsightTitle] = useState('') + // Translation State + const [translationResult, setTranslationResult] = useState(null) + const [activeTab, setActiveTab] = useState<'transcript' | 'translation'>('transcript'); + const fetchMeetings = async () => { try { const res = await axios.get(`${API_BASE}/meetings`) @@ -170,6 +174,43 @@ export default function App() { } } + const handleTranslate = async () => { + if (!detailMeeting) return; + setInsightLoading(true); + + try { + // Use a consistent key for the translation type + const translationType = 'translation_english'; + + // Check if result already exists in the main meeting object + const existing = detailMeeting.analysis_results?.find(r => r.prompt_key === translationType); + if (existing) { + setTranslationResult(existing); + // Here you might want to switch to a "Translation" tab or show a modal + alert("Translation already exists and has been loaded."); + return; + } + + // If not, generate it + const res = await axios.post(`${API_BASE}/meetings/${detailMeeting.id}/translate`, { + target_language: 'English' + }); + + // The API returns an AnalysisResult object, let's store it + setTranslationResult(res.data); + + // Also refresh the main meeting detail to include this new result for persistence + fetchDetail(detailMeeting.id); + setActiveTab('translation'); + alert("Translation successful!"); + + } catch(e) { + console.error("Failed to generate translation", e); + alert(`Error: Could not translate.`); + } finally { + setInsightLoading(false); + } + } // --- GLOBAL SPEAKER MANAGEMENT --- @@ -378,17 +419,17 @@ export default function App() { )} - {/* AI INSIGHTS PANEL */}
-

AI Insights

+

AI Tools

{insightLoading && }
-
+
+
@@ -402,116 +443,143 @@ export default function App() { )}
- {flatMessages.length > 0 ? ( -
- {flatMessages.map((msg, uniqueIdx) => { - const isEditingSpeaker = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'speaker'; - const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text'; + {/* TAB Navigation */} +
+ + {translationResult && ( + + )} +
- return ( -
- {/* Time */} -
- {msg.display_time || "00:00"} -
+ {/* Conditional Content */} + {activeTab === 'transcript' && ( + flatMessages.length > 0 ? ( +
+ {flatMessages.map((msg, uniqueIdx) => { + const isEditingSpeaker = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'speaker'; + const isEditingText = editingRow?.chunkId === msg._chunkId && editingRow?.idx === msg._idx && editingRow?.field === 'text'; -
- {/* Speaker */} -
- {isEditingSpeaker ? ( - setEditValue(e.target.value)} - onBlur={() => handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} - onKeyDown={e => e.key === 'Enter' && handleUpdateRow(msg._chunkId!, msg._idx!, 'speaker', editValue)} - /> - ) : ( -
{ setEditingRow({chunkId: msg._chunkId!, idx: msg._idx!, field: 'speaker'}); setEditValue(msg.speaker); }} - > - {msg.speaker} -
- )} -
+ return ( +
+ {/* Time */} +
+ {msg.display_time || "00:00"} +
- {/* Text */} -
- {isEditingText ? ( -