const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue; // Wait for the DOM to be fully loaded before mounting the Vue app document.addEventListener("DOMContentLoaded", async () => { // Initialize i18n before creating Vue app if (window.i18n) { const appElement = document.getElementById("app"); const userLang = appElement?.dataset.userLanguage || localStorage.getItem("preferredLanguage") || "en"; await window.i18n.init(userLang); console.log("i18n initialized with language:", userLang); } // CSRF Token Integration with Vue.js const csrfToken = ref( document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ); // Register Service Worker only when the browser accepts the current TLS context. if ("serviceWorker" in navigator && window.isSecureContext && window.location.hostname !== "localhost") { window.addEventListener("load", () => { navigator.serviceWorker .register("/tool/speakr/static/sw.js") .then((registration) => { console.log( "ServiceWorker registration successful with scope: ", registration.scope ); }) .catch((error) => { console.log("ServiceWorker registration failed: ", error); }); }); } // Create a safe t function that's always available const safeT = (key, params = {}) => { if (!window.i18n || !window.i18n.t) { return key; // Return key as fallback without warning during initial render } return window.i18n.t(key, params); }; let webReader = null; let webReader2 = null; // ASR Recorder 实例(提升到外层以便跨录音周期清理) let rec = null; let rec2 = null; // openMixedStream 创建的 AudioContext(需跨周期清理) let asrAudioContext = null; let asrAudioContext2 = null; // 用于标记是否需要重置转录状态(暂停/恢复时使用) let resetTranscriptionFlag = false; let pendingCorrectionCount = 0; const app = createApp({ setup() { // --- Core State --- // 新增说话人占位 let segmentList = []; let lastSpeaker = ""; let configwords = ""; const currentView = ref("upload"); // 'upload' or 'recording' const dragover = ref(false); const recordings = ref([]); const selectedRecording = ref(null); const selectedTab = ref("summary"); // 'summary' or 'notes' const searchQuery = ref(""); const isLoadingRecordings = ref(true); const globalError = ref(null); // Advanced filter state const showAdvancedFilters = ref(false); const filterTags = ref([]); // Selected tag IDs for filtering const filterDateRange = ref({ start: "", end: "" }); const filterDatePreset = ref(""); // 'today', 'yesterday', 'week', 'month', etc. const filterTextQuery = ref(""); // --- Pagination State --- const currentPage = ref(1); const perPage = ref(25); const totalRecordings = ref(0); const totalPages = ref(0); const hasNextPage = ref(false); const hasPrevPage = ref(false); const isLoadingMore = ref(false); const searchDebounceTimer = ref(null); // --- Enhanced Search & Organization State --- const sortBy = ref("created_at"); // 'created_at' or 'meeting_date' const selectedTagFilter = ref(null); // For filtering by clicked tag // --- UI State --- const browser = ref("unknown"); const isSidebarCollapsed = ref(false); const searchTipsExpanded = ref(false); const isUserMenuOpen = ref(false); const isDarkMode = ref(false); const currentColorScheme = ref("blue"); const showColorSchemeModal = ref(false); const windowWidth = ref(window.innerWidth); const mobileTab = ref("transcript"); const isMetadataExpanded = ref(false); // --- i18n State --- const currentLanguage = ref("en"); const currentLanguageName = ref("English"); const availableLanguages = ref([]); const showLanguageMenu = ref(false); // --- Upload State --- const uploadQueue = ref([]); const currentlyProcessingFile = ref(null); const processingProgress = ref(0); const processingMessage = ref(""); const isProcessingActive = ref(false); const pollInterval = ref(null); const progressPopupMinimized = ref(false); const progressPopupClosed = ref(false); const maxFileSizeMB = ref(250); // Default value, will be updated from API const chunkingEnabled = ref(true); // Default value, will be updated from API const chunkingMode = ref("size"); // 'size' or 'duration', will be updated from API const chunkingLimit = ref(20); // Value in MB or seconds, will be updated from API const chunkingLimitDisplay = ref("20MB"); // Human readable display, will be updated from API const recordingDisclaimer = ref(""); // Recording disclaimer text from admin settings const showRecordingDisclaimerModal = ref(false); // Controls disclaimer modal visibility const pendingRecordingMode = ref(null); // Stores the recording mode while showing disclaimer // --- Audio Recording State --- const editingDraftId = ref(null); const editingDraftName = ref(''); const draftNameInput = ref(null); const isRecording = ref(false); const isStoppingRecording = ref(false); const mediaRecorder = ref(null); const audioChunks = ref([]); const audioBlobURL = ref(null); const recordingTime = ref(0); const recordingInterval = ref(null); const canRecordAudio = ref( navigator.mediaDevices && navigator.mediaDevices.getUserMedia ); const canRecordSystemAudio = ref(false); const systemAudioSupported = ref(false); const systemAudioError = ref(""); const recordingNotes = ref(""); const showSystemAudioHelp = ref(false); // ASR options for recording view const asrLanguage = ref(""); // Empty string for auto-detect const asrMinSpeakers = ref(""); // Empty string for auto-detect const asrMaxSpeakers = ref(""); // Empty string for auto-detect const audioContext = ref(null); const analyser = ref(null); const micAnalyser = ref(null); const systemAnalyser = ref(null); const visualizer = ref(null); const micVisualizer = ref(null); const systemVisualizer = ref(null); const animationFrameId = ref(null); const recordingMode = ref("microphone"); // 'microphone', 'system', or 'both' const activeStreams = ref([]); // Track active streams for cleanup // --- Recording Size Monitoring --- const estimatedFileSize = ref(0); const fileSizeWarningShown = ref(false); const recordingQuality = ref("optimized"); // 'optimized', 'standard', 'high' const actualBitrate = ref(0); const maxRecordingMB = ref(200); // Maximum recording size before auto-stop const sizeCheckInterval = ref(null); // --- Draft/Pause Recording State --- const isPaused = ref(false); const currentDraftId = ref(null); const pausedDuration = ref(0); // 暂停前的累计时长 const pausedTranscription = ref(''); // 暂停前的转录文本 const draftRecordings = ref([]); // 草稿列表 const draftsExpanded = ref(true); // 草稿列表是否展开 // Advanced Options for ASR const showAdvancedOptions = ref(false); const uploadLanguage = ref(""); // Empty string for auto-detect const uploadMinSpeakers = ref(""); // Empty string for auto-detect const uploadMaxSpeakers = ref(""); // Empty string for auto-detect // Tag Selection const availableTags = ref([]); const selectedTagIds = ref([]); // Changed to array for multiple selection const uploadTagSearchFilter = ref(""); // For filtering tags in upload view const selectedTags = computed(() => { return selectedTagIds.value .map((tagId) => availableTags.value.find((tag) => tag.id == tagId)) .filter(Boolean); // Filter out undefined tags }); // 自动判断是否为 Markdown const isMarkdown = computed(() => { const v = analysisResult.value; return ( v.includes("#") || v.includes("**") || v.includes("```") || v.includes("* ") || v.includes("- ") ); }); // 解析 Markdown → HTML const renderedMarkdown = Vue.computed(() => { return window.marked.parse(analysisResult.value || "", { breaks: true, }); }); // Computed property for filtered available tags in upload view const filteredAvailableTagsForUpload = computed(() => { const availableForSelection = availableTags.value.filter( (tag) => !selectedTagIds.value.includes(tag.id) ); if (!uploadTagSearchFilter.value) return availableForSelection; const filter = uploadTagSearchFilter.value.toLowerCase(); return availableForSelection.filter((tag) => tag.name.toLowerCase().includes(filter) ); }); // --- Modal State --- const showEditModal = ref(false); const showDeleteModal = ref(false); const showEditTagsModal = ref(false); const selectedNewTagId = ref(""); const tagSearchFilter = ref(""); // For filtering tags in the modal const showReprocessModal = ref(false); const showResetModal = ref(false); const showSpeakerModal = ref(false); const showShareModal = ref(false); const showSharesListModal = ref(false); const showTextEditorModal = ref(false); const showAsrEditorModal = ref(false); const editingRecording = ref(null); const editingTranscriptionContent = ref(""); const editingSegments = ref([]); const availableSpeakers = ref([]); const recordingToShare = ref(null); const shareOptions = reactive({ share_summary: true, share_notes: true, }); const generatedShareLink = ref(""); const existingShareDetected = ref(false); const userShares = ref([]); const isLoadingShares = ref(false); const shareToDelete = ref(null); const showShareDeleteModal = ref(false); const recordingToDelete = ref(null); const recordingToReset = ref(null); const reprocessType = ref(null); // 'transcription' or 'summary' const reprocessRecording = ref(null); const isAutoIdentifying = ref(false); const asrReprocessOptions = reactive({ language: "", min_speakers: null, max_speakers: null, }); const speakerMap = ref({}); const regenerateSummaryAfterSpeakerUpdate = ref(true); const speakerSuggestions = ref({}); const loadingSuggestions = ref({}); const activeSpeakerInput = ref(null); // --- Inline Editing State --- const editingParticipants = ref(false); const editingMeetingDate = ref(false); const editingSummary = ref(false); const editingNotes = ref(false); const tempNotesContent = ref(""); const tempSummaryContent = ref(""); const autoSaveTimer = ref(null); const autoSaveDelay = 2000; // 2 seconds debounce // --- Markdown Editor State --- const notesMarkdownEditor = ref(null); const markdownEditorInstance = ref(null); const summaryMarkdownEditor = ref(null); const summaryMarkdownEditorInstance = ref(null); const recordingNotesEditor = ref(null); const recordingMarkdownEditorInstance = ref(null); // --- Transcription State --- const transcriptionViewMode = ref("simple"); // 'simple' or 'bubble' const legendExpanded = ref(false); const highlightedSpeaker = ref(null); // --- Chat State --- const showChat = ref(false); const isChatMaximized = ref(false); const chatMessages = ref([]); const chatInput = ref(""); const isChatLoading = ref(false); const chatMessagesRef = ref(null); // --- Audio Player State --- const playerVolume = ref(1.0); // --- Column Resizing State --- const leftColumnWidth = ref(60); // 60% for left column (transcript) const rightColumnWidth = ref(40); // 40% for right column (summary/chat) const isResizing = ref(false); // --- App Configuration --- const useAsrEndpoint = ref(false); const currentUserName = ref(""); const asrResult = ref(""); const asrResultOnline = ref(""); const asrResultOffline = ref(""); const asrResultTextarea = ref(""); const asrResultTextareaOnline = ref(""); const sumupResultTextarea = ref(""); const baseInfo = ref({ name: "领导发言稿", fontSize: 40 }); const isModalOpen = ref(false); const asrTextarea = ref(null); const analysisResult = ref(""); const isDialogVisible = ref(false); const isyjDialogVisible = ref(false); const lastTimer = ref(0); const urlmsg = ref("获取链接中..."); const urlinfo = ref(""); const zjloding = ref(false); const showSpeaker = ref(true); // --- 转录文本编辑状态 --- const isEditingTranscription = ref(false); const editableText = ref(""); const fullRealtimeTranscription = computed(() => { return ( (asrResult.value || "") + (asrResultOffline.value || "") + (asrResultOnline.value || "") ); }); let segmentRenderStartIndex = 0; let liveTranscriptionEpoch = 0; let realtimeTimestampBarrierMs = -1; let realtimeEditCarryover = null; let clearRealtimePipelineRef = null; let getLatestRealtimeTimestampRef = null; let buildRealtimeEditCarryoverRef = null; let renderFullTextRef = null; let editModeBufferStartIndex = 0; function startNewRealtimeEpoch({ barrierTimestampMs = null, clearTimestampBarrier = false, clearEditCarryover = true, } = {}) { liveTranscriptionEpoch += 1; if (clearEditCarryover) { realtimeEditCarryover = null; } if (clearTimestampBarrier) { realtimeTimestampBarrierMs = -1; } else if (Number.isFinite(barrierTimestampMs)) { realtimeTimestampBarrierMs = Math.max( realtimeTimestampBarrierMs, barrierTimestampMs ); } if (clearRealtimePipelineRef) { clearRealtimePipelineRef({ preserveTimestampBarrier: !clearTimestampBarrier, }); } } // --- Computed Properties --- const isMobileScreen = computed(() => { return windowWidth.value < 1024; }); const datePresetOptions = computed(() => { return [ { value: "today", label: t("sidebar.today") }, { value: "yesterday", label: t("sidebar.yesterday") }, { value: "thisweek", label: t("sidebar.thisWeek") }, { value: "lastweek", label: t("sidebar.lastWeek") }, { value: "thismonth", label: t("sidebar.thisMonth") }, { value: "lastmonth", label: t("sidebar.lastMonth") }, ]; }); const languageOptions = computed(() => { return [ { value: "", label: t("form.autoDetect") }, { value: "en", label: t("languages.en") }, { value: "es", label: t("languages.es") }, { value: "fr", label: t("languages.fr") }, { value: "de", label: t("languages.de") }, { value: "it", label: t("languages.it") }, { value: "pt", label: t("languages.pt") }, { value: "nl", label: t("languages.nl") }, { value: "ru", label: t("languages.ru") }, { value: "zh", label: t("languages.zh") }, { value: "ja", label: t("languages.ja") }, { value: "ko", label: t("languages.ko") }, ]; }); const filteredRecordings = computed(() => { return recordings.value; }); const highlightedTranscript = computed(() => { if (!selectedRecording.value?.transcription) return ""; let html = selectedRecording.value.transcription; // Escape HTML to prevent injection, but keep it minimal html = html.replace(//g, ">"); // 1. Replace newlines with
tags for proper line breaks in HTML html = html.replace(/\n/g, "
"); // 2. Get speaker colors from the speakerMap if available const speakerColors = {}; if (speakerMap.value) { Object.keys(speakerMap.value).forEach((speaker, index) => { speakerColors[speaker] = speakerMap.value[speaker].color || `speaker-color-${(index % 8) + 1}`; }); } // 3. Wrap each speaker tag in a span for styling and interaction with colors html = html.replace(/\[([^\]]+)\]/g, (match, speakerId) => { const isHighlighted = speakerId === highlightedSpeaker.value; const colorClass = speakerColors[speakerId] || ""; // Replace speaker ID with name if available const displayName = speakerMap.value[speakerId]?.name || speakerId; const displayText = `[${displayName}]`; // Use a more specific and stylish class structure with color return `${displayText}`; }); return html; }); const activeRecordingMetadata = computed(() => { if (!selectedRecording.value) return []; const recording = selectedRecording.value; const metadata = []; if (recording.created_at) { metadata.push({ icon: "fas fa-history", text: formatDisplayDate(recording.created_at), }); } if (recording.file_size) { metadata.push({ icon: "fas fa-file-audio", text: formatFileSize(recording.file_size), }); } if (recording.duration) { metadata.push({ icon: "fas fa-clock", text: formatDuration(recording.duration), }); } if (recording.original_filename) { const maxLength = 30; const truncated = recording.original_filename.length > maxLength ? recording.original_filename.substring(0, maxLength) + "..." : recording.original_filename; metadata.push({ icon: "fas fa-file", text: truncated, fullText: recording.original_filename, }); } // Add tags to metadata if (recording.tags && recording.tags.length > 0) { metadata.push({ icon: "fas fa-tags", text: "", // Empty text since we'll render tags specially tags: recording.tags, // Pass the tags array isTagItem: true, // Flag to identify this as a tag item }); } return metadata; }); const groupedRecordings = computed(() => { // Sort recordings based on the selected sort criteria const sortedRecordings = [...filteredRecordings.value].sort((a, b) => { const dateA = getDateForSorting(a); const dateB = getDateForSorting(b); // Handle null dates (put them at the end) if (!dateA && !dateB) return 0; if (!dateA) return 1; if (!dateB) return -1; return dateB - dateA; // Most recent first }); const groups = { today: [], yesterday: [], thisWeek: [], lastWeek: [], thisMonth: [], lastMonth: [], older: [], }; sortedRecordings.forEach((recording) => { const date = getDateForSorting(recording); if (!date) { groups.older.push(recording); return; } if (isToday(date)) { groups.today.push(recording); } else if (isYesterday(date)) { groups.yesterday.push(recording); } else if (isThisWeek(date)) { groups.thisWeek.push(recording); } else if (isLastWeek(date)) { groups.lastWeek.push(recording); } else if (isThisMonth(date)) { groups.thisMonth.push(recording); } else if (isLastMonth(date)) { groups.lastMonth.push(recording); } else { groups.older.push(recording); } }); return [ { title: t("sidebar.today"), items: groups.today }, { title: t("sidebar.yesterday"), items: groups.yesterday }, { title: t("sidebar.thisWeek"), items: groups.thisWeek }, { title: t("sidebar.lastWeek"), items: groups.lastWeek }, { title: t("sidebar.thisMonth"), items: groups.thisMonth }, { title: t("sidebar.lastMonth"), items: groups.lastMonth }, { title: t("sidebar.older"), items: groups.older }, ].filter((g) => g.items.length > 0); }); const totalInQueue = computed(() => uploadQueue.value.length); const completedInQueue = computed( () => uploadQueue.value.filter( (item) => item.status === "completed" || item.status === "failed" ).length ); const finishedFilesInQueue = computed(() => uploadQueue.value.filter((item) => ["completed", "failed"].includes(item.status) ) ); const showRecordingControls = computed(() => { if (currentView.value !== "recording") { return false; } const hasCompletedRecording = !isRecording.value && !isPaused.value && !!audioBlobURL.value; if (hasCompletedRecording) { return false; } if (isRecording.value || isPaused.value || isStoppingRecording.value) { return true; } return ( !audioBlobURL.value && (activeStreams.value.length > 0 || recordingTime.value > 0 || !!currentDraftId.value) ); }); const clearCompletedUploads = () => { uploadQueue.value = uploadQueue.value.filter( (item) => !["completed", "failed"].includes(item.status) ); }; const identifiedSpeakers = computed(() => { // Ensure we have a valid recording and transcription if (!selectedRecording.value?.transcription) { return []; } const transcription = selectedRecording.value.transcription; let transcriptionData; try { transcriptionData = JSON.parse(transcription); } catch (e) { transcriptionData = null; } // Updated to handle new simplified JSON format (array of segments) if (transcriptionData && Array.isArray(transcriptionData)) { // JSON format - extract speakers in order of appearance const speakersInOrder = []; const seenSpeakers = new Set(); transcriptionData.forEach((segment) => { if ( segment.speaker && String(segment.speaker).trim() && !seenSpeakers.has(segment.speaker) ) { seenSpeakers.add(segment.speaker); speakersInOrder.push(segment.speaker); } }); return speakersInOrder; // Keep order of appearance, don't sort } else if (typeof transcription === "string") { // Plain text format - find speakers in order of appearance const speakerRegex = /\[([^\]]+)\]:/g; const speakersInOrder = []; const seenSpeakers = new Set(); let match; while ((match = speakerRegex.exec(transcription)) !== null) { const speaker = match[1].trim(); if (speaker && !seenSpeakers.has(speaker)) { seenSpeakers.add(speaker); speakersInOrder.push(speaker); } } return speakersInOrder; // Keep order of appearance, don't sort } return []; }); // identifiedSpeakersInOrder is now just an alias since identifiedSpeakers already preserves order const identifiedSpeakersInOrder = computed(() => { return identifiedSpeakers.value; }); const hasSpeakerNames = computed(() => { // Check if any speaker has a non-empty name return Object.values(speakerMap.value).some( (speakerData) => speakerData.name && speakerData.name.trim() !== "" ); }); const processedTranscription = computed(() => { if (!selectedRecording.value?.transcription) { return { hasDialogue: false, content: "", speakers: [], simpleSegments: [], bubbleRows: [], }; } const transcription = selectedRecording.value.transcription; let transcriptionData; try { transcriptionData = JSON.parse(transcription); } catch (e) { transcriptionData = null; } // Handle new simplified JSON format (array of segments) if (transcriptionData && Array.isArray(transcriptionData)) { const wasDiarized = transcriptionData.some( (segment) => segment.speaker ); if (!wasDiarized) { const segments = transcriptionData.map((segment) => ({ sentence: segment.sentence, startTime: segment.start_time, })); return { hasDialogue: false, isJson: true, content: segments.map((s) => s.sentence).join("\n"), simpleSegments: segments, speakers: [], bubbleRows: [], }; } // Extract unique speakers const speakers = [ ...new Set( transcriptionData .map((segment) => segment.speaker) .filter(Boolean) ), ]; const speakerColors = {}; speakers.forEach((speaker, index) => { speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`; }); const simpleSegments = transcriptionData.map((segment) => ({ speakerId: segment.speaker, speaker: speakerMap.value[segment.speaker]?.name || segment.speaker, sentence: segment.sentence, startTime: segment.start_time || segment.startTime, endTime: segment.end_time || segment.endTime, color: speakerColors[segment.speaker] || "speaker-color-1", })); const processedSimpleSegments = []; let lastSpeakerId = null; simpleSegments.forEach((segment) => { processedSimpleSegments.push({ ...segment, showSpeaker: segment.speakerId !== lastSpeakerId, }); lastSpeakerId = segment.speakerId; }); const bubbleRows = []; let lastBubbleSpeakerId = null; simpleSegments.forEach((segment) => { if ( bubbleRows.length === 0 || segment.speakerId !== lastBubbleSpeakerId ) { bubbleRows.push({ speaker: segment.speaker, color: segment.color, isMe: segment.speaker && typeof segment.speaker === "string" && segment.speaker.toLowerCase().includes("me"), bubbles: [], }); lastBubbleSpeakerId = segment.speakerId; } bubbleRows[bubbleRows.length - 1].bubbles.push({ sentence: segment.sentence, startTime: segment.startTime || segment.start_time, color: segment.color, }); }); return { hasDialogue: true, isJson: true, segments: simpleSegments, simpleSegments: processedSimpleSegments, bubbleRows: bubbleRows, speakers: speakers.map((speaker) => ({ name: speakerMap.value[speaker]?.name || speaker, color: speakerColors[speaker], })), }; } else { // Fallback for plain text transcription const speakerRegex = /\[([^\]]+)\]:\s*/g; const hasDialogue = speakerRegex.test(transcription); if (!hasDialogue) { return { hasDialogue: false, isJson: false, content: transcription, speakers: [], simpleSegments: [], bubbleRows: [], }; } speakerRegex.lastIndex = 0; const speakers = new Set(); let match; while ((match = speakerRegex.exec(transcription)) !== null) { speakers.add(match[1]); } const speakerList = Array.from(speakers); const speakerColors = {}; speakerList.forEach((speaker, index) => { speakerColors[speaker] = `speaker-color-${(index % 8) + 1}`; }); const segments = []; const lines = transcription.split("\n"); let currentSpeakerId = null; let currentText = ""; for (const line of lines) { const speakerMatch = line.match(/^\[([^\]]+)\]:\s*(.*)$/); if (speakerMatch) { if (currentSpeakerId && currentText.trim()) { segments.push({ speakerId: currentSpeakerId, speaker: speakerMap.value[currentSpeakerId]?.name || currentSpeakerId, sentence: currentText.trim(), color: speakerColors[currentSpeakerId] || "speaker-color-1", }); } currentSpeakerId = speakerMatch[1]; currentText = speakerMatch[2]; } else if (currentSpeakerId && line.trim()) { currentText += " " + line.trim(); } else if (!currentSpeakerId && line.trim()) { segments.push({ speakerId: null, speaker: null, sentence: line.trim(), color: "speaker-color-1", }); } } if (currentSpeakerId && currentText.trim()) { segments.push({ speakerId: currentSpeakerId, speaker: speakerMap.value[currentSpeakerId]?.name || currentSpeakerId, sentence: currentText.trim(), color: speakerColors[currentSpeakerId] || "speaker-color-1", }); } const simpleSegments = []; let lastSpeakerId = null; segments.forEach((segment) => { simpleSegments.push({ ...segment, showSpeaker: segment.speakerId !== lastSpeakerId, sentence: segment.sentence || segment.text, }); lastSpeakerId = segment.speakerId; }); const bubbleRows = []; let currentRow = null; segments.forEach((segment) => { if (!currentRow || currentRow.speakerId !== segment.speakerId) { if (currentRow) bubbleRows.push(currentRow); currentRow = { speakerId: segment.speakerId, speaker: segment.speaker, color: segment.color, bubbles: [], isMe: segment.speaker && segment.speaker.toLowerCase().includes("me"), }; } currentRow.bubbles.push({ sentence: segment.sentence, color: segment.color, }); }); if (currentRow) bubbleRows.push(currentRow); return { hasDialogue: true, isJson: false, segments: segments, simpleSegments: simpleSegments, bubbleRows: bubbleRows, speakers: speakerList.map((speaker) => ({ name: speakerMap.value[speaker]?.name || speaker, color: speakerColors[speaker] || "speaker-color-1", })), }; } }); // --- Color Scheme Management --- const colorSchemes = { light: [ { id: "blue", name: "Ocean Blue", description: "Classic blue theme with professional appeal", class: "", }, { id: "emerald", name: "Forest Emerald", description: "Fresh green theme for a natural feel", class: "theme-light-emerald", }, { id: "purple", name: "Royal Purple", description: "Elegant purple theme with sophistication", class: "theme-light-purple", }, { id: "rose", name: "Sunset Rose", description: "Warm pink theme with gentle energy", class: "theme-light-rose", }, { id: "amber", name: "Golden Amber", description: "Warm yellow theme for brightness", class: "theme-light-amber", }, { id: "teal", name: "Ocean Teal", description: "Cool teal theme for tranquility", class: "theme-light-teal", }, ], dark: [ { id: "blue", name: "Midnight Blue", description: "Deep blue theme for focused work", class: "", }, { id: "emerald", name: "Dark Forest", description: "Rich green theme for comfortable viewing", class: "theme-dark-emerald", }, { id: "purple", name: "Deep Purple", description: "Mysterious purple theme for creativity", class: "theme-dark-purple", }, { id: "rose", name: "Dark Rose", description: "Muted pink theme with subtle warmth", class: "theme-dark-rose", }, { id: "amber", name: "Dark Amber", description: "Warm brown theme for cozy sessions", class: "theme-dark-amber", }, { id: "teal", name: "Deep Teal", description: "Dark teal theme for calm focus", class: "theme-dark-teal", }, ], }; // --- Utility Methods --- const openmodel = async (e) => { urlmsg.value = "获取链接中..."; isModalOpen.value = true; let urls = `/tool/speakr/api/external/summarize_text`; const response = await fetch(urls, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_str: asrResult.value, prompt_id: e ? e.id : "", }), }); const data = await response.json(); if (response) { urlinfo.value = data.string; urlmsg.value = "点击打开投屏页面"; console.log(data.value, "urlinfo.value", response, data); } if (!response.ok) { throw new Error(data.error || "Failed to reset status"); } }; const openzjmodel = async () => { zjloding.value = true; let urls = `/tool/speakr/api/external/submit`; try { const response = await fetch(urls, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_str: analysisResult.value, }), }); const data = await response.json(); if (response) { window.open(data.string, "_blank"); zjloding.value = false; } if (!response.ok) { zjloding.value = false; throw new Error(data.error || "Failed to reset status"); } } catch (error) { console.error("接口请求失败:", error); zjloding.value = false; } }; const closeModal = () => { isModalOpen.value = false; }; const setGlobalError = (message, duration = 7000) => { globalError.value = message; if (duration > 0) { setTimeout(() => { if (globalError.value === message) globalError.value = null; }, duration); } }; const formatFileSize = (bytes) => { if (bytes == null || bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; if (bytes < 0) bytes = 0; const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k))); const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2)); return size + " " + sizes[i]; }; const formatDisplayDate = (dateString) => { if (!dateString) return ""; try { // Try to parse the date string directly first (it might already be formatted) let date = new Date(dateString); // If that fails or results in invalid date, try different approaches if (isNaN(date.getTime())) { // Try appending time if it looks like a date-only string (YYYY-MM-DD format) if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { date = new Date(dateString + "T00:00:00"); } else { // If it's already a formatted string, just return it return dateString; } } // If we still have an invalid date, return the original string if (isNaN(date.getTime())) { return dateString; } return date.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric", }); } catch (e) { console.error("Error formatting date:", e); return dateString; } }; const formatStatus = (status) => { if (!status || status === "COMPLETED") return ""; const statusMap = { PENDING: t("status.queued"), PROCESSING: t("status.processing"), TRANSCRIBING: t("status.transcribing"), SUMMARIZING: t("status.summarizing"), FAILED: t("status.failed"), UPLOADING: t("status.uploading"), }; return ( statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() ); }; const getStatusClass = (status) => { switch (status) { case "PENDING": return "status-pending"; case "PROCESSING": return "status-processing"; case "SUMMARIZING": return "status-summarizing"; case "COMPLETED": return ""; case "FAILED": return "status-failed"; default: return "status-pending"; } }; const formatTime = (seconds) => { const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes.toString().padStart(2, "0")}:${secs .toString() .padStart(2, "0")}`; }; const formatDuration = (totalSeconds) => { if (totalSeconds == null || totalSeconds < 0) return "N/A"; if (totalSeconds < 1) { return `${totalSeconds.toFixed(2)} seconds`; } totalSeconds = Math.round(totalSeconds); if (totalSeconds < 60) { return `${totalSeconds} sec`; } const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; let parts = []; if (hours > 0) { parts.push(`${hours} hr`); } if (minutes > 0) { parts.push(`${minutes} min`); } // Only show seconds if duration is less than an hour if (hours === 0 && seconds > 0) { parts.push(`${seconds} sec`); } return parts.join(" "); }; const createLocalizedRecordingFilename = () => { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); return `录音_${timestamp}.webm`; }; const getDownloadFilenameFromHeaders = ( contentDisposition, fallbackFilename ) => { if (!contentDisposition) { return fallbackFilename; } const utf8Match = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition); if (utf8Match && utf8Match[1]) { return decodeURIComponent(utf8Match[1]); } const regularMatch = /filename="([^"]+)"/i.exec(contentDisposition); if (regularMatch && regularMatch[1]) { return regularMatch[1]; } return fallbackFilename; }; // --- Recording Size Monitoring Functions --- const updateFileSizeEstimate = () => { if (!isRecording.value || !actualBitrate.value) return; // Calculate estimated size based on recording time and bitrate const recordingTimeSeconds = recordingTime.value; const estimatedBits = actualBitrate.value * recordingTimeSeconds; const estimatedBytes = estimatedBits / 8; estimatedFileSize.value = estimatedBytes; // Check if we're approaching the size limit const sizeMB = estimatedBytes / (1024 * 1024); const warningThresholdMB = maxRecordingMB.value * 0.8; // 80% of max size if (sizeMB > warningThresholdMB && !fileSizeWarningShown.value) { fileSizeWarningShown.value = true; showToast( `Recording size is ${formatFileSize( estimatedBytes )}. Consider stopping soon to avoid auto-stop at ${ maxRecordingMB.value }MB.`, "fa-exclamation-triangle", 5000 ); } // Auto-stop if we exceed the maximum size if (sizeMB > maxRecordingMB.value) { console.log( `Auto-stopping recording: size ${formatFileSize( estimatedBytes )} exceeds limit of ${maxRecordingMB.value}MB` ); stopRecording(); showToast( `Recording automatically stopped at ${formatFileSize( estimatedBytes )} to prevent excessive file size.`, "fa-stop-circle", 7000 ); } }; const startSizeMonitoring = () => { if (sizeCheckInterval.value) { clearInterval(sizeCheckInterval.value); } // Reset size monitoring state estimatedFileSize.value = 0; fileSizeWarningShown.value = false; // Start monitoring every 5 seconds sizeCheckInterval.value = setInterval(updateFileSizeEstimate, 5000); }; const stopSizeMonitoring = () => { if (sizeCheckInterval.value) { clearInterval(sizeCheckInterval.value); sizeCheckInterval.value = null; } }; const promptVisible = ref(false); const promptList = ref([]); const recommendPromptId = ref(null); const recommendPromptName = ref(""); const recommendPromptReason = ref(""); const selectedPromptId = ref( localStorage.getItem("selected_prompt_id") || null ); // const selectedPromptId = ref(null) let modelTypeForP = ""; /** 打开弹框 */ const openPromptModal = (e) => { modelTypeForP = e; recommendPromptId.value = null; recommendPromptName.value = ""; recommendPromptReason.value = ""; promptVisible.value = true; getPromptList(e); getRecommend(); }; /** 关闭弹框 */ const closePromptModal = () => { promptVisible.value = false; }; /** 获取 Prompt 列表 */ const getPromptList = async (e) => { try { const res = await fetch( `/tool/speakr/api/external/prompts/list`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skip: 0, limit: 999 }), } ); const data = await res.json(); promptList.value = data.prompt_list || []; } catch (e) { console.error("提示词加载失败", e); } }; /** 根据文本获取Prompt推荐 */ const getRecommend = async (e) => { try { const res = await fetch( `/tool/speakr/api/external/recommend_prompts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_str: asrResult.value }), } ); const data = await res.json(); recommendPromptId.value = data.id; recommendPromptName.value = data.name; recommendPromptReason.value = data.reason; } catch (e) { console.error("获取推荐失败", e); } }; /** 确认选择 */ const confirmPromptSelection = () => { if (selectedPromptId.value) { localStorage.setItem("selected_prompt_id", selectedPromptId.value); } const selectedPrompt = promptList.value.find( (i) => i.id == selectedPromptId.value ); console.log("选中的提示词:", selectedPrompt); if (modelTypeForP === "zj") { sumupResult(selectedPrompt); } else { openmodel(selectedPrompt); } // emit("promptSelected", selectedPrompt) promptVisible.value = false; }; const sumupResult = async (e) => { isDialogVisible.value = true; // if (!asrResult.value) return; try { analysisResult.value = "思考中..."; const response = await fetch( `/tool/speakr/api/external/summarize_text_stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input_str: asrResult.value, // input_str: '香港火灾情况更新** 香港特区政府召开新闻发布会,通报火灾的搜救与安置最新进展。警方表示,已完成5座大厦的搜索,其余2座仍在进行中。至当天16时,火灾已造成 **156人遇难**,目前有约35人参与搜救工作,其中5人连续工作。2. **对话内容提及天气** 有用户询问“今天天气怎么样”,地点为“安康”,表明对话中涉及对当地天气的关注。3. **对话片段含非正式表达** 有用户发言“我是走开始啊不是”,语句不通顺,可能是口语化表达或输入错误,语义不明确。', prompt_id: e ? e.id : "", }), } ); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to create share link"); } // 重置结果并开始流式处理 analysisResult.value = ""; const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; // 解码并处理流数据 const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); if (data.content) { analysisResult.value += data.content; } if (data.finished) { console.log(analysisResult.value, "analysisResult.value"); showToast("总结完成!", "fa-check-circle"); break; } } catch (e) { console.log("解析流数据出错:", e); } } } } } catch (error) { setGlobalError(`Failed to create share link: ${error.message}`); } }; // --- Enhanced Date Utility Functions --- const getDateForSorting = (recording) => { const dateStr = sortBy.value === "meeting_date" ? recording.meeting_date : recording.created_at; if (!dateStr) return null; return new Date(dateStr); }; const isToday = (date) => { const today = new Date(); return isSameDay(date, today); }; const isYesterday = (date) => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); return isSameDay(date, yesterday); }; const isThisWeek = (date) => { const now = new Date(); const startOfWeek = new Date(now); const day = now.getDay(); const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week startOfWeek.setDate(diff); startOfWeek.setHours(0, 0, 0, 0); const endOfWeek = new Date(startOfWeek); endOfWeek.setDate(startOfWeek.getDate() + 6); endOfWeek.setHours(23, 59, 59, 999); return date >= startOfWeek && date <= endOfWeek; }; const isLastWeek = (date) => { const now = new Date(); const startOfLastWeek = new Date(now); const day = now.getDay(); const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday startOfLastWeek.setDate(diff); startOfLastWeek.setHours(0, 0, 0, 0); const endOfLastWeek = new Date(startOfLastWeek); endOfLastWeek.setDate(startOfLastWeek.getDate() + 6); endOfLastWeek.setHours(23, 59, 59, 999); return date >= startOfLastWeek && date <= endOfLastWeek; }; const isThisMonth = (date) => { const now = new Date(); return ( date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() ); }; const isLastMonth = (date) => { const now = new Date(); const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); return ( date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth() ); }; const isSameDay = (date1, date2) => { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); }; const reprocessTranscription = (recordingId) => { const recording = recordings.value.find((r) => r.id === recordingId) || selectedRecording.value; confirmReprocess("transcription", recording); }; const reprocessSummary = (recordingId) => { const recording = recordings.value.find((r) => r.id === recordingId) || selectedRecording.value; confirmReprocess("summary", recording); }; const generateSummary = async () => { if (!selectedRecording.value) return; try { const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute("content"); const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/generate_summary`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken, }, } ); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Failed to generate summary"); } // Update the recording status to show it's being processed selectedRecording.value.status = "SUMMARIZING"; // Also update in recordings list if it exists const recordingInList = recordings.value.find( (r) => r.id === selectedRecording.value.id ); if (recordingInList) { recordingInList.status = "SUMMARIZING"; } showToast("Summary generation started", "success"); } catch (error) { console.error("Error generating summary:", error); setGlobalError(`Failed to generate summary: ${error.message}`); } }; const resetRecordingStatus = async (recordingId) => { const recording = recordings.value.find((r) => r.id === recordingId); if (!recording) return; try { const response = await fetch( `/tool/speakr/recording/${recording.id}/reset_status`, { method: "POST", headers: { "Content-Type": "application/json" }, } ); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Failed to reset status"); } const index = recordings.value.findIndex( (r) => r.id === recording.id ); if (index !== -1) { recordings.value[index] = data.recording; } if (selectedRecording.value?.id === recording.id) { selectedRecording.value = data.recording; } showToast("Recording status has been reset.", "fa-check-circle"); } catch (error) { setGlobalError(`Failed to reset status: ${error.message}`); } }; const confirmReprocess = (type, recording) => { reprocessType.value = type; reprocessRecording.value = recording; showReprocessModal.value = true; }; const openTranscriptionEditor = () => { if (processedTranscription.value.isJson) { openAsrEditorModal(); } else { openTextEditorModal(); } }; const openTextEditorModal = () => { if (!selectedRecording.value) return; editingTranscriptionContent.value = selectedRecording.value.transcription; showTextEditorModal.value = true; }; const closeTextEditorModal = () => { showTextEditorModal.value = false; editingTranscriptionContent.value = ""; }; const saveTranscription = async () => { if (!selectedRecording.value) return; await saveTranscriptionContent(editingTranscriptionContent.value); closeTextEditorModal(); }; const openAsrEditorModal = async () => { if (!selectedRecording.value) return; try { const segments = JSON.parse(selectedRecording.value.transcription); editingSegments.value = segments.map((s, i) => ({ ...s, id: i, showSuggestions: false, filteredSpeakers: [], })); // Populate available speakers const speakersInTranscript = [ ...new Set(segments.map((s) => s.speaker)), ]; const response = await fetch("/tool/speakr/speakers"); const speakersFromDb = await response.json(); const speakerNamesFromDb = speakersFromDb.map((s) => s.name); availableSpeakers.value = [ ...new Set([...speakersInTranscript, ...speakerNamesFromDb]), ].sort(); showAsrEditorModal.value = true; } catch (e) { console.error( "Could not parse transcription as JSON for ASR editor:", e ); setGlobalError( "This transcription is not in the correct format for the ASR editor." ); } }; const closeAsrEditorModal = () => { showAsrEditorModal.value = false; editingSegments.value = []; availableSpeakers.value = []; }; const saveAsrTranscription = async () => { const contentToSave = JSON.stringify( editingSegments.value.map( ({ id, showSuggestions, filteredSpeakers, ...rest }) => rest ) ); await saveTranscriptionContent(contentToSave); closeAsrEditorModal(); }; const adjustTime = (index, field, amount) => { if (editingSegments.value[index]) { editingSegments.value[index][field] = parseFloat( (editingSegments.value[index][field] + amount).toFixed(3) ); } }; const filterSpeakers = (index) => { const segment = editingSegments.value[index]; if (segment) { const query = segment.speaker.toLowerCase(); segment.filteredSpeakers = availableSpeakers.value.filter((s) => s.toLowerCase().includes(query) ); } }; const openSpeakerSuggestions = (index) => { if (editingSegments.value[index]) { editingSegments.value[index].showSuggestions = true; filterSpeakers(index); } }; const closeSpeakerSuggestions = (index) => { if (editingSegments.value[index]) { window.setTimeout(() => { if (editingSegments.value[index]) { editingSegments.value[index].showSuggestions = false; } }, 200); // Delay to allow click event to register } }; const selectSpeaker = (index, speaker) => { if (editingSegments.value[index]) { editingSegments.value[index].speaker = speaker; editingSegments.value[index].showSuggestions = false; } }; const addSegment = () => { const lastSegment = editingSegments.value[editingSegments.value.length - 1]; editingSegments.value.push({ id: Date.now(), speaker: lastSegment ? lastSegment.speaker : "SPEAKER_00", start_time: lastSegment ? lastSegment.end_time : 0, end_time: lastSegment ? lastSegment.end_time + 1 : 1, sentence: "", }); }; const removeSegment = (index) => { editingSegments.value.splice(index, 1); }; const saveTranscriptionContent = async (content) => { if (!selectedRecording.value) return; try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/update_transcription`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transcription: content }), } ); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to update transcription"); const index = recordings.value.findIndex( (r) => r.id === selectedRecording.value.id ); if (index !== -1) { recordings.value[index] = data.recording; } selectedRecording.value = data.recording; showToast("Transcription updated successfully!", "fa-check-circle"); } catch (error) { console.error("Save Transcription Error:", error); setGlobalError(`Failed to save transcription: ${error.message}`); } }; const cancelReprocess = () => { showReprocessModal.value = false; reprocessType.value = null; reprocessRecording.value = null; }; const executeReprocess = async () => { if (!reprocessRecording.value || !reprocessType.value) return; const recordingId = reprocessRecording.value.id; const type = reprocessType.value; // Close the modal first cancelReprocess(); if (type === "transcription") { await performReprocessTranscription( recordingId, asrReprocessOptions.language, asrReprocessOptions.min_speakers, asrReprocessOptions.max_speakers ); } else if (type === "summary") { await performReprocessSummary(recordingId); } }; const performReprocessTranscription = async ( recordingId, language, minSpeakers, maxSpeakers ) => { if (!recordingId) { setGlobalError("No recording ID provided for reprocessing."); return; } try { const payload = { language: language, min_speakers: minSpeakers, max_speakers: maxSpeakers, }; const response = await fetch( `/tool/speakr/recording/${recordingId}/reprocess_transcription`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), } ); const data = await response.json(); if (!response.ok) throw new Error( data.error || "Failed to start transcription reprocessing" ); // Ensure the recording status is properly set to PROCESSING if (data.recording && data.recording.status !== "PROCESSING") { console.warn( `Warning: Reprocess transcription returned unexpected status: ${data.recording.status}` ); data.recording.status = "PROCESSING"; } // Update the recording in the UI const index = recordings.value.findIndex((r) => r.id === recordingId); if (index !== -1) { recordings.value[index] = data.recording; } // Update selected recording if it's the one being reprocessed if (selectedRecording.value?.id === recordingId) { selectedRecording.value = data.recording; } showToast("Transcription reprocessing started", "fa-sync-alt"); // Switch to Summary tab to show progress selectedTab.value = "summary"; // Show progress modal for reprocessing showProgressModalForReprocessing(recordingId, "transcription"); // Start polling for status updates startReprocessingPoll(recordingId); } catch (error) { console.error("Reprocess Transcription Error:", error); setGlobalError( `Failed to start transcription reprocessing: ${error.message}` ); } }; const performReprocessSummary = async (recordingId) => { if (!recordingId) { setGlobalError("No recording ID provided for reprocessing."); return; } try { const response = await fetch( `/tool/speakr/recording/${recordingId}/reprocess_summary`, { method: "POST", headers: { "Content-Type": "application/json" }, } ); const data = await response.json(); if (!response.ok) throw new Error( data.error || "Failed to start summary reprocessing" ); // Ensure the recording status is properly set to SUMMARIZING if (data.recording && data.recording.status !== "SUMMARIZING") { console.warn( `Warning: Reprocess summary returned unexpected status: ${data.recording.status}` ); data.recording.status = "SUMMARIZING"; } // Update the recording in the UI const index = recordings.value.findIndex((r) => r.id === recordingId); if (index !== -1) { recordings.value[index] = data.recording; } // Update selected recording if it's the one being reprocessed if (selectedRecording.value?.id === recordingId) { selectedRecording.value = data.recording; } showToast("Summary reprocessing started", "fa-sync-alt"); // Switch to Summary tab to show progress selectedTab.value = "summary"; // Show progress modal for reprocessing showProgressModalForReprocessing(recordingId, "summary"); // Start polling for status updates startReprocessingPoll(recordingId); } catch (error) { console.error("Reprocess Summary Error:", error); setGlobalError( `Failed to start summary reprocessing: ${error.message}` ); } }; // Show progress modal for reprocessing operations const showProgressModalForReprocessing = (recordingId, type) => { const recording = recordings.value.find((r) => r.id === recordingId); if (!recording) return; // Create a mock file item for the progress modal const reprocessItem = { file: { name: recording.title || `Recording ${recordingId}`, size: recording.file_size || 0, }, status: "pending", recordingId: recordingId, clientId: `reprocess-${type}-${recordingId}-${Date.now()}`, error: null, isReprocessing: true, reprocessType: type, }; // Add to upload queue for progress tracking uploadQueue.value.unshift(reprocessItem); // Show progress modal progressPopupMinimized.value = false; progressPopupClosed.value = false; // Set as currently processing file currentlyProcessingFile.value = reprocessItem; processingProgress.value = 10; processingMessage.value = type === "transcription" ? "Starting transcription reprocessing..." : "Starting summary reprocessing..."; }; // Polling for reprocessing status updates const reprocessingPolls = ref(new Map()); // Track active polls by recording ID const startReprocessingPoll = (recordingId) => { // Clear any existing poll for this recording if (reprocessingPolls.value.has(recordingId)) { clearInterval(reprocessingPolls.value.get(recordingId)); } // console.log(`Starting reprocessing poll for recording ${recordingId}`); const pollInterval = setInterval(async () => { try { const response = await fetch(`/tool/speakr/status/${recordingId}`); if (!response.ok) { console.error(`Status check failed for recording ${recordingId}`); stopReprocessingPoll(recordingId); return; } const data = await response.json(); // Update the recording in the UI const index = recordings.value.findIndex( (r) => r.id === recordingId ); if (index !== -1) { recordings.value[index] = data; } // Update selected recording if it's the one being reprocessed if (selectedRecording.value?.id === recordingId) { selectedRecording.value = data; } const queueItem = uploadQueue.value.find( (item) => item.recordingId === recordingId ); // Update the item's status and name for intermediate states if (queueItem) { queueItem.status = data.status; // e.g., 'PROCESSING', 'SUMMARIZING' queueItem.file.name = data.title || data.original_filename; } // Update progress modal if this is the currently processing item if ( queueItem && currentlyProcessingFile.value?.clientId === queueItem.clientId ) { updateReprocessingProgress(data.status, queueItem); } // Stop polling if processing is complete if (data.status === "COMPLETED" || data.status === "FAILED") { console.log( `Reprocessing ${data.status.toLowerCase()} for recording ${recordingId}` ); stopReprocessingPoll(recordingId); // Update queue item status to final lowercase state if (queueItem) { queueItem.status = data.status === "COMPLETED" ? "completed" : "failed"; if (data.status === "FAILED") { queueItem.error = data.error_message || "Reprocessing failed"; } } // Clear current processing if this was the active item if (currentlyProcessingFile.value?.recordingId === recordingId) { resetCurrentFileProcessingState(); } if (data.status === "COMPLETED") { showToast( "Reprocessing completed successfully", "fa-check-circle" ); } else { showToast("Reprocessing failed", "fa-exclamation-circle"); } } } catch (error) { console.error( `Error polling status for recording ${recordingId}:`, error ); stopReprocessingPoll(recordingId); } }, 3000); reprocessingPolls.value.set(recordingId, pollInterval); }; const updateReprocessingProgress = (status, queueItem) => { switch (status) { case "PENDING": processingProgress.value = 20; processingMessage.value = `Waiting to start ${queueItem.reprocessType} reprocessing...`; break; case "PROCESSING": processingProgress.value = Math.round( Math.min(70, processingProgress.value + Math.random() * 10) ); processingMessage.value = queueItem.reprocessType === "transcription" ? "Reprocessing transcription..." : "Processing audio..."; break; case "SUMMARIZING": processingProgress.value = Math.round( Math.min(90, processingProgress.value + Math.random() * 10) ); processingMessage.value = queueItem.reprocessType === "summary" ? "Regenerating summary..." : "Generating title and summary..."; break; case "COMPLETED": processingProgress.value = 100; processingMessage.value = "Reprocessing completed!"; break; case "FAILED": processingProgress.value = 100; processingMessage.value = "Reprocessing failed."; break; default: processingProgress.value = 15; processingMessage.value = "Starting reprocessing..."; } }; const stopReprocessingPoll = (recordingId) => { if (reprocessingPolls.value.has(recordingId)) { clearInterval(reprocessingPolls.value.get(recordingId)); reprocessingPolls.value.delete(recordingId); console.log(`Stopped reprocessing poll for recording ${recordingId}`); } }; const confirmReset = (recording) => { recordingToReset.value = recording; showResetModal.value = true; }; const cancelReset = () => { showResetModal.value = false; recordingToReset.value = null; }; const executeReset = async () => { if (!recordingToReset.value) return; const recordingId = recordingToReset.value.id; // Close the modal first cancelReset(); try { const response = await fetch( `/tool/speakr/recording/${recordingId}/reset_status`, { method: "POST", headers: { "Content-Type": "application/json" }, } ); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to reset status"); // Update the recording in the UI const index = recordings.value.findIndex((r) => r.id === recordingId); if (index !== -1) { recordings.value[index] = data.recording; } // Update selected recording if it's the one being reset if (selectedRecording.value?.id === recordingId) { selectedRecording.value = data.recording; } showToast("Recording status has been reset.", "fa-check-circle"); } catch (error) { console.error("Reset Status Error:", error); setGlobalError(`Failed to reset status: ${error.message}`); } }; const searchSpeakers = async (query, speakerId) => { if (!query || query.length < 2) { speakerSuggestions.value[speakerId] = []; return; } loadingSuggestions.value[speakerId] = true; try { const response = await fetch( `/tool/speakr/speakers/search?q=${encodeURIComponent(query)}` ); if (!response.ok) throw new Error("Failed to search speakers"); const speakers = await response.json(); speakerSuggestions.value[speakerId] = speakers; } catch (error) { console.error("Error searching speakers:", error); speakerSuggestions.value[speakerId] = []; } finally { loadingSuggestions.value[speakerId] = false; } }; const selectSpeakerSuggestion = (speakerId, suggestion) => { if (speakerMap.value[speakerId]) { speakerMap.value[speakerId].name = suggestion.name; speakerSuggestions.value[speakerId] = []; } }; const closeSpeakerSuggestionsOnClick = (event) => { // Check if the click was on an input field or dropdown const clickedInput = event.target.closest('input[type="text"]'); const clickedDropdown = event.target.closest(".absolute.z-10"); // If not clicking on input or dropdown, close all suggestions if (!clickedInput && !clickedDropdown) { Object.keys(speakerSuggestions.value).forEach((speakerId) => { speakerSuggestions.value[speakerId] = []; }); } }; // Create a mapping for display-friendly speaker IDs const speakerDisplayMap = ref({}); const modalSpeakers = ref([]); const is_final = ref(false); const openSpeakerModal = () => { // Clear any existing speaker map data first speakerMap.value = {}; speakerDisplayMap.value = {}; // Get the same speaker order used in processedTranscription const transcription = selectedRecording.value?.transcription; let speakers = []; if (transcription) { try { const transcriptionData = JSON.parse(transcription); if (transcriptionData && Array.isArray(transcriptionData)) { // Use the exact same logic as processedTranscription to get speakers speakers = [ ...new Set( transcriptionData .map((segment) => segment.speaker) .filter(Boolean) ), ]; } } catch (e) { // Fall back to identifiedSpeakers if JSON parsing fails speakers = identifiedSpeakers.value; } } // Set modalSpeakers for the template to use modalSpeakers.value = speakers; // Initialize speaker map with the same order and colors as the transcript speakerMap.value = speakers.reduce((acc, speaker, index) => { acc[speaker] = { name: "", isMe: false, color: `speaker-color-${(index % 8) + 1}`, // Same color assignment as processedTranscription }; // Keep the original speaker ID for display speakerDisplayMap.value[speaker] = speaker; return acc; }, {}); highlightedSpeaker.value = null; speakerSuggestions.value = {}; loadingSuggestions.value = {}; isAutoIdentifying.value = false; showSpeakerModal.value = true; }; const closeSpeakerModal = () => { showSpeakerModal.value = false; highlightedSpeaker.value = null; // Clear the speaker map to prevent stale data from persisting speakerMap.value = {}; speakerSuggestions.value = {}; loadingSuggestions.value = {}; // Clean up click handler if it exists if (window.speakerModalClickHandler) { const modalContent = document.querySelector(".modal-content"); if (modalContent) { modalContent.removeEventListener( "click", window.speakerModalClickHandler ); } delete window.speakerModalClickHandler; } }; const saveSpeakerNames = async () => { if (!selectedRecording.value) return; // Create a filtered speaker map that excludes entries with blank names const filteredSpeakerMap = Object.entries(speakerMap.value).reduce( (acc, [speakerId, speakerData]) => { if (speakerData.name && speakerData.name.trim() !== "") { acc[speakerId] = speakerData; } return acc; }, {} ); try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/update_speakers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ speaker_map: filteredSpeakerMap, // Send the filtered map regenerate_summary: regenerateSummaryAfterSpeakerUpdate.value, }), } ); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to update speakers"); // On success, close the modal and clear the speakerMap state *before* // updating the recording data. This prevents a race condition where the // view could re-render using the new data but the old, lingering speakerMap. closeSpeakerModal(); // The backend returns the fully updated recording object. // We can directly update our local state with this fresh data. const index = recordings.value.findIndex( (r) => r.id === selectedRecording.value.id ); if (index !== -1) { recordings.value[index] = data.recording; } selectedRecording.value = data.recording; showToast("Speaker names updated successfully!", "fa-check-circle"); // If a summary regeneration was requested, start polling for its status. if (regenerateSummaryAfterSpeakerUpdate.value) { startReprocessingPoll(selectedRecording.value.id); } } catch (error) { console.error("Save Speaker Names Error:", error); setGlobalError(`Failed to save speaker names: ${error.message}`); } }; // Speaker group navigation state const currentSpeakerGroupIndex = ref(-1); const speakerGroups = ref([]); const findSpeakerGroups = (speakerId) => { if (!speakerId) return []; const groups = []; const modalTranscript = document.querySelector( "div.speaker-modal-transcript" ); const mainTranscript = document.querySelector( ".transcription-simple-view, .transcription-with-speakers, .transcription-content" ); const transcriptContainer = modalTranscript || mainTranscript; if (!transcriptContainer) return []; // For JSON-based transcripts with segments const allSegments = transcriptContainer.querySelectorAll(".speaker-segment"); if (allSegments.length > 0) { let currentGroup = null; let lastSpeakerId = null; allSegments.forEach((segment) => { const speakerTag = segment.querySelector("[data-speaker-id]"); const segmentSpeakerId = speakerTag?.dataset.speakerId; if (segmentSpeakerId === speakerId) { // If this is a new group (not consecutive with previous) if (lastSpeakerId !== speakerId) { currentGroup = { startElement: segment, elements: [segment], }; groups.push(currentGroup); } else if (currentGroup) { // Add to existing group currentGroup.elements.push(segment); } } lastSpeakerId = segmentSpeakerId; }); } else { // For plain text transcripts with speaker tags const allTags = transcriptContainer.querySelectorAll("[data-speaker-id]"); let currentGroup = null; allTags.forEach((tag) => { if (tag.dataset.speakerId === speakerId) { // Find the parent element that contains this speaker's content const parentSegment = tag.closest(".speaker-segment") || tag.parentElement; if ( !currentGroup || !currentGroup.lastElement || !parentSegment.previousElementSibling || parentSegment.previousElementSibling !== currentGroup.lastElement ) { // Start a new group currentGroup = { startElement: parentSegment, elements: [parentSegment], lastElement: parentSegment, }; groups.push(currentGroup); } else { // Continue the group currentGroup.elements.push(parentSegment); currentGroup.lastElement = parentSegment; } } }); } return groups; }; const highlightSpeakerInTranscript = (speakerId) => { highlightedSpeaker.value = speakerId; if (speakerId) { // Find all speaker groups for navigation speakerGroups.value = findSpeakerGroups(speakerId); currentSpeakerGroupIndex.value = 0; // Scroll to the first group if (speakerGroups.value.length > 0) { nextTick(() => { const firstGroup = speakerGroups.value[0]; if (firstGroup && firstGroup.startElement) { firstGroup.startElement.scrollIntoView({ behavior: "smooth", block: "center", }); } }); } } else { speakerGroups.value = []; currentSpeakerGroupIndex.value = -1; } }; const navigateToNextSpeakerGroup = () => { if (speakerGroups.value.length === 0) return; // Don't reset the speaker groups, just update the index currentSpeakerGroupIndex.value = (currentSpeakerGroupIndex.value + 1) % speakerGroups.value.length; const group = speakerGroups.value[currentSpeakerGroupIndex.value]; if (group && group.startElement) { group.startElement.scrollIntoView({ behavior: "smooth", block: "center", }); } }; const navigateToPrevSpeakerGroup = () => { if (speakerGroups.value.length === 0) return; // Don't reset the speaker groups, just update the index currentSpeakerGroupIndex.value = currentSpeakerGroupIndex.value <= 0 ? speakerGroups.value.length - 1 : currentSpeakerGroupIndex.value - 1; const group = speakerGroups.value[currentSpeakerGroupIndex.value]; if (group && group.startElement) { group.startElement.scrollIntoView({ behavior: "smooth", block: "center", }); } }; // Enhanced speaker highlighting with focus/blur events for text inputs const focusSpeaker = (speakerId) => { // Set this as the active speaker input activeSpeakerInput.value = speakerId; // Only highlight if not already highlighted (to preserve navigation state) if (highlightedSpeaker.value !== speakerId) { highlightSpeakerInTranscript(speakerId); } }; const blurSpeaker = () => { // Clear the active speaker input after a delay to allow clicking on suggestions setTimeout(() => { activeSpeakerInput.value = null; speakerSuggestions.value = {}; }, 200); clearSpeakerHighlight(); }; const clearSpeakerHighlight = () => { highlightedSpeaker.value = null; }; const autoIdentifySpeakers = async () => { if (!selectedRecording.value) { showToast("No recording selected.", "fa-exclamation-circle"); return; } isAutoIdentifying.value = true; showToast("Starting automatic speaker identification...", "fa-magic"); try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/auto_identify_speakers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_speaker_map: speakerMap.value, }), } ); const data = await response.json(); if (!response.ok) { throw new Error( data.error || "Unknown error occurred during auto-identification." ); } // Check if there's a message (e.g., all speakers already identified) if (data.message) { showToast(data.message, "fa-info-circle"); return; } // Update speakerMap with the identified names (only for unidentified speakers) let identifiedCount = 0; for (const speakerId in data.speaker_map) { const identifiedName = data.speaker_map[speakerId]; if ( speakerMap.value[speakerId] && identifiedName && identifiedName.trim() !== "" ) { speakerMap.value[speakerId].name = identifiedName; identifiedCount++; } } if (identifiedCount > 0) { showToast( `${identifiedCount} speaker(s) identified successfully!`, "fa-check-circle" ); } else { showToast( "No speakers could be identified from the context.", "fa-info-circle" ); } } catch (error) { console.error("Auto Identify Speakers Error:", error); showToast(`Error: ${error.message}`, "fa-exclamation-circle", 5000); } finally { isAutoIdentifying.value = false; } }; const toggleInbox = async (recording) => { if (!recording || !recording.id) return; try { const response = await fetch( `/tool/speakr/recording/${recording.id}/toggle_inbox`, { method: "POST", headers: { "Content-Type": "application/json" }, } ); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to toggle inbox status"); // Update the recording in the UI recording.is_inbox = data.is_inbox; // Update in the recordings list const index = recordings.value.findIndex( (r) => r.id === recording.id ); if (index !== -1) { recordings.value[index].is_inbox = data.is_inbox; } showToast( `Recording ${data.is_inbox ? "moved to inbox" : "marked as read"}` ); } catch (error) { console.error("Toggle Inbox Error:", error); setGlobalError(`Failed to toggle inbox status: ${error.message}`); } }; // Toggle highlighted status const toggleHighlight = async (recording) => { if (!recording || !recording.id) return; try { const response = await fetch( `/tool/speakr/recording/${recording.id}/toggle_highlight`, { method: "POST", headers: { "Content-Type": "application/json" }, } ); const data = await response.json(); if (!response.ok) throw new Error( data.error || "Failed to toggle highlighted status" ); // Update the recording in the UI recording.is_highlighted = data.is_highlighted; // Update in the recordings list const index = recordings.value.findIndex( (r) => r.id === recording.id ); if (index !== -1) { recordings.value[index].is_highlighted = data.is_highlighted; } showToast( `Recording ${data.is_highlighted ? "highlighted" : "unhighlighted"}` ); } catch (error) { console.error("Toggle Highlight Error:", error); setGlobalError( `Failed to toggle highlighted status: ${error.message}` ); } }; // --- Dark Mode --- const toggleDarkMode = () => { isDarkMode.value = !isDarkMode.value; if (isDarkMode.value) { document.documentElement.classList.add("dark"); localStorage.setItem("darkMode", "true"); } else { document.documentElement.classList.remove("dark"); localStorage.setItem("darkMode", "false"); } }; const initializeDarkMode = () => { const prefersDark = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; const savedMode = localStorage.getItem("darkMode"); if (savedMode === "true" || (savedMode === null && prefersDark)) { isDarkMode.value = true; document.documentElement.classList.add("dark"); } else { isDarkMode.value = false; document.documentElement.classList.remove("dark"); } }; const applyColorScheme = (schemeId, mode = null) => { const targetMode = mode || (isDarkMode.value ? "dark" : "light"); const scheme = colorSchemes[targetMode].find((s) => s.id === schemeId); if (!scheme) { console.warn( `Color scheme '${schemeId}' not found for mode '${targetMode}'` ); return; } const allThemeClasses = [ ...colorSchemes.light.map((s) => s.class), ...colorSchemes.dark.map((s) => s.class), ].filter((c) => c !== ""); document.documentElement.classList.remove(...allThemeClasses); if (scheme.class) { document.documentElement.classList.add(scheme.class); } currentColorScheme.value = schemeId; localStorage.setItem("colorScheme", schemeId); }; const initializeColorScheme = () => { const savedScheme = localStorage.getItem("colorScheme") || "blue"; currentColorScheme.value = savedScheme; applyColorScheme(savedScheme); }; const openColorSchemeModal = () => { showColorSchemeModal.value = true; }; const closeColorSchemeModal = () => { showColorSchemeModal.value = false; }; const selectColorScheme = (schemeId) => { applyColorScheme(schemeId); showToast( `Applied ${ colorSchemes[isDarkMode.value ? "dark" : "light"].find( (s) => s.id === schemeId )?.name } theme`, "fa-palette" ); }; const resetColorScheme = () => { applyColorScheme("blue"); showToast("Reset to default Ocean Blue theme", "fa-undo"); }; // Watch for dark mode changes to reapply color scheme watch(isDarkMode, () => { applyColorScheme(currentColorScheme.value); }); function confirmStopRecording() { return new Promise((resolve) => { // 防止重复创建 if (document.getElementById("recording-confirm-mask")) { return; } // 遮罩层 const mask = document.createElement("div"); mask.id = "recording-confirm-mask"; mask.style.cssText = ` position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9999; display: flex; align-items: center; justify-content: center; `; // 弹框 const dialog = document.createElement("div"); dialog.style.cssText = ` width: 360px; background: #1f2937; color: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.3); font-family: system-ui; `; dialog.innerHTML = `
确认停止录音
当前正在录音,切换页面将会停止录音,是否继续?
`; mask.appendChild(dialog); document.body.appendChild(mask); const cleanup = () => { document.body.removeChild(mask); }; dialog.querySelector("#confirm-ok").onclick = () => { cleanup(); resolve(true); }; dialog.querySelector("#confirm-cancel").onclick = () => { cleanup(); resolve(false); }; // 点击遮罩关闭 = 取消 mask.onclick = (e) => { if (e.target === mask) { cleanup(); resolve(false); } }; }); } // --- Sidebar Toggle --- const toggleSidebar = () => { isSidebarCollapsed.value = !isSidebarCollapsed.value; }; // --- View Management --- const switchToUploadView = async () => { if (currentView.value === "recording" && isRec === true) { const confirmed = await confirmStopRecording(); if (!confirmed) return; currentView.value = "upload"; selectedRecording.value = null; if (isMobileScreen.value) { isSidebarCollapsed.value = true; } } else { currentView.value = "upload"; selectedRecording.value = null; if (isMobileScreen.value) { isSidebarCollapsed.value = true; } } }; const selectRecording = async (recording) => { if (currentView.value === "recording" && isRecording.value) { // If we are in the middle of a recording, don't switch views setGlobalError( "请先停止当前录音,再选择其他录音。" ); return; } selectedRecording.value = recording; if (currentView.value === "recording" && isRec === true) { const confirmed = await confirmStopRecording(); if (!confirmed) return; if (currentView.value === "recording" && isRec === true) { const confirmed = await confirmStopRecording(); if (!confirmed) return; currentView.value = "detail"; } else { currentView.value = "detail"; } if (recording && recording.id) { localStorage.setItem("lastSelectedRecordingId", recording.id); } else { localStorage.removeItem("lastSelectedRecordingId"); } if (isMobileScreen.value) { isSidebarCollapsed.value = true; } } else { if (currentView.value === "recording" && isRec === true) { const confirmed = await confirmStopRecording(); if (!confirmed) return; currentView.value = "detail"; } else { currentView.value = "detail"; } if (recording && recording.id) { localStorage.setItem("lastSelectedRecordingId", recording.id); } else { localStorage.removeItem("lastSelectedRecordingId"); } if (isMobileScreen.value) { isSidebarCollapsed.value = true; } } }; // --- File Upload --- const handleDragOver = (e) => { e.preventDefault(); dragover.value = true; }; const handleDragLeave = (e) => { if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) { return; } dragover.value = false; }; const handleDrop = (e) => { e.preventDefault(); dragover.value = false; addFilesToQueue(e.dataTransfer.files); }; const handleFileSelect = (e) => { addFilesToQueue(e.target.files); e.target.value = null; }; const addFilesToQueue = (files) => { let filesAdded = 0; for (const file of files) { const fileObject = file.file ? file.file : file; const notes = file.notes || null; const tags = file.tags || selectedTags.value || []; const asrOptions = file.asrOptions || { language: asrLanguage.value, min_speakers: asrMinSpeakers.value, max_speakers: asrMaxSpeakers.value, }; // Check if it's an audio file or video container with audio const isAudioFile = fileObject && (fileObject.type.startsWith("audio/") || fileObject.type === "video/mp4" || fileObject.type === "video/quicktime" || fileObject.type === "video/x-msvideo" || fileObject.type === "video/webm" || fileObject.name.toLowerCase().endsWith(".amr") || fileObject.name.toLowerCase().endsWith(".3gp") || fileObject.name.toLowerCase().endsWith(".3gpp") || fileObject.name.toLowerCase().endsWith(".mp4") || fileObject.name.toLowerCase().endsWith(".mov") || fileObject.name.toLowerCase().endsWith(".avi") || fileObject.name.toLowerCase().endsWith(".mkv") || fileObject.name.toLowerCase().endsWith(".webm")); if (isAudioFile) { // Only check general file size limit (chunking handles OpenAI 25MB limit automatically) if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) { setGlobalError( `File "${fileObject.name}" exceeds the maximum size of ${maxFileSizeMB.value} MB and was skipped.` ); continue; } const clientId = `client-${Date.now()}-${Math.random() .toString(36) .substring(2, 9)}`; // Auto-summarization will always occur for all uploads const willAutoSummarize = true; uploadQueue.value.push({ file: fileObject, notes: notes, tags: tags, asrOptions: asrOptions, status: "queued", recordingId: null, clientId: clientId, error: null, willAutoSummarize: willAutoSummarize, }); filesAdded++; } else if (fileObject) { setGlobalError( `Invalid file type "${fileObject.name}". Only audio files and video containers with audio (MP3, WAV, MP4, MOV, AVI, etc.) are accepted. File skipped.` ); } } if (filesAdded > 0) { console.log(`Added ${filesAdded} file(s) to the queue.`); progressPopupMinimized.value = false; progressPopupClosed.value = false; if (!isProcessingActive.value) { startProcessingQueue(); } } }; const resetCurrentFileProcessingState = () => { if (pollInterval.value) clearInterval(pollInterval.value); pollInterval.value = null; currentlyProcessingFile.value = null; processingProgress.value = 0; processingMessage.value = ""; }; const startProcessingQueue = async () => { console.log("Attempting to start processing queue..."); if (isProcessingActive.value) { console.log("Queue processor already active."); return; } isProcessingActive.value = true; resetCurrentFileProcessingState(); const nextFileItem = uploadQueue.value.find( (item) => item.status === "queued" ); if (nextFileItem) { console.log( `Processing next file: ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId})` ); currentlyProcessingFile.value = nextFileItem; // Check if this is a "reload" item (existing recording being tracked) if (nextFileItem.clientId.startsWith("reload-")) { // Skip upload, go directly to polling existing recording console.log( `Skipping upload for existing recording: ${nextFileItem.recordingId}` ); nextFileItem.status = "processing"; startStatusPolling(nextFileItem, nextFileItem.recordingId); return; } nextFileItem.status = "uploading"; processingMessage.value = "Preparing upload..."; processingProgress.value = 5; try { const formData = new FormData(); formData.append("file", nextFileItem.file); if (nextFileItem.notes) { formData.append("notes", nextFileItem.notes); } // Add tags if selected (multiple tags) // Use tags from the queue item if available, otherwise use global selectedTagIds const tagsToUse = nextFileItem.tags || selectedTags.value || []; tagsToUse.forEach((tag, index) => { const tagId = tag.id || tag; // Handle both tag objects and tag IDs formData.append(`tag_ids[${index}]`, tagId); }); // Add ASR advanced options if ASR endpoint is enabled if (useAsrEndpoint.value) { // Use ASR options from the queue item if available, otherwise use global values const asrOpts = nextFileItem.asrOptions || {}; const language = asrOpts.language || uploadLanguage.value; const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value; const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value; if (language) { formData.append("language", language); } // Only send speaker limits if they're actually set if (minSpeakers && minSpeakers !== "") { formData.append("min_speakers", minSpeakers.toString()); } if (maxSpeakers && maxSpeakers !== "") { formData.append("max_speakers", maxSpeakers.toString()); } } processingMessage.value = "Uploading file..."; processingProgress.value = 10; const response = await fetch("/tool/speakr/upload", { method: "POST", body: formData, }); const data = await response.json(); if (!response.ok) { let errorMsg = data.error || `Upload failed with status ${response.status}`; if (response.status === 413) errorMsg = data.error || `File too large. Max: ${ data.max_size_mb?.toFixed(0) || maxFileSizeMB.value } MB.`; throw new Error(errorMsg); } if (response.status === 202 && data.id) { console.log( `File ${nextFileItem.file.name} uploaded. Recording ID: ${data.id}. Starting status poll.` ); nextFileItem.status = "pending"; nextFileItem.recordingId = data.id; processingMessage.value = "Upload complete. Waiting for processing..."; processingProgress.value = 30; recordings.value.unshift(data); totalRecordings.value++; // Update total count pollProcessingStatus(nextFileItem); } else { throw new Error( "Unexpected success response from server after upload." ); } } catch (error) { console.error( `Upload/Processing Error for ${nextFileItem.file.name} (Client ID: ${nextFileItem.clientId}):`, error ); nextFileItem.status = "failed"; nextFileItem.error = error.message; const failedRecordIndex = recordings.value.findIndex( (r) => r.id === nextFileItem.recordingId ); if (failedRecordIndex !== -1) { recordings.value[failedRecordIndex].status = "FAILED"; recordings.value[ failedRecordIndex ].transcription = `Upload/Processing failed: ${error.message}`; } else { setGlobalError( `Failed to process "${nextFileItem.file.name}": ${error.message}` ); } resetCurrentFileProcessingState(); isProcessingActive.value = false; await nextTick(); startProcessingQueue(); } } else { console.log("Upload queue is empty or no files are queued."); isProcessingActive.value = false; } }; const startStatusPolling = (fileItem, recordingId) => { fileItem.recordingId = recordingId; pollProcessingStatus(fileItem); }; const pollProcessingStatus = (fileItem) => { if (pollInterval.value) clearInterval(pollInterval.value); const recordingId = fileItem.recordingId; if (!recordingId) { console.error( "Cannot poll status without recording ID for", fileItem.file.name ); fileItem.status = "failed"; fileItem.error = "Internal error: Missing recording ID for polling."; resetCurrentFileProcessingState(); isProcessingActive.value = false; nextTick(startProcessingQueue); return; } processingMessage.value = "Waiting for transcription..."; processingProgress.value = 40; pollInterval.value = setInterval(async () => { // Check if we should stop polling const shouldStopPolling = !currentlyProcessingFile.value || currentlyProcessingFile.value.clientId !== fileItem.clientId || fileItem.status === "failed" || (fileItem.status === "completed" && (!fileItem.willAutoSummarize || fileItem.summaryCompleted)); if (shouldStopPolling) { console.log( `Polling stopped for ${fileItem.clientId} as it's no longer active or finished.` ); clearInterval(pollInterval.value); pollInterval.value = null; if ( currentlyProcessingFile.value && currentlyProcessingFile.value.clientId === fileItem.clientId ) { resetCurrentFileProcessingState(); isProcessingActive.value = false; await nextTick(); startProcessingQueue(); } return; } try { console.log( `Polling status for recording ID: ${recordingId} (${fileItem.file.name})` ); const response = await fetch(`/tool/speakr/status/${recordingId}`); if (!response.ok) throw new Error( `Status check failed with status ${response.status}` ); const data = await response.json(); const galleryIndex = recordings.value.findIndex( (r) => r.id === recordingId ); if (galleryIndex !== -1) { recordings.value[galleryIndex] = data; if (selectedRecording.value?.id === recordingId) { selectedRecording.value = data; } } const previousStatus = fileItem.status; fileItem.status = data.status; fileItem.file.name = data.title || data.original_filename; if (data.status === "COMPLETED") { console.log( `Processing COMPLETED for ${fileItem.file.name} (ID: ${recordingId})` ); // If this was previously summarizing, it's now fully complete if (previousStatus === "summarizing") { console.log(`Auto-summary completed for ${fileItem.file.name}`); processingMessage.value = "Processing complete!"; processingProgress.value = 100; fileItem.status = "completed"; fileItem.summaryCompleted = true; // This is final completion - clean up immediately and synchronously clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; // Keep completed items visible in the modal - don't remove them console.log( `Completed item ${fileItem.clientId} will remain visible in queue` ); // Use immediate startProcessingQueue instead of nextTick to avoid duplication startProcessingQueue(); return; // Exit early to prevent further processing } // If auto-summarization will occur and hasn't started yet, wait for it else if ( fileItem.willAutoSummarize && !fileItem.hasCheckedForAutoSummary ) { processingMessage.value = "Transcription complete!"; processingProgress.value = 85; fileItem.status = "awaiting_summary"; // Use intermediate status to keep it in upload queue // Don't mark as summaryCompleted yet, continue polling } // No auto-summarization expected, complete normally else { processingMessage.value = "Processing complete!"; processingProgress.value = 100; fileItem.status = "completed"; fileItem.summaryCompleted = true; // No summary expected, so consider it complete // Complete immediately for files without auto-summarization clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; // Keep completed items visible in the modal - don't remove them console.log( `Completed item ${fileItem.clientId} will remain visible in queue` ); startProcessingQueue(); return; // Exit early to prevent further processing } // For files with auto-summarization, mark that they've been checked and continue polling if ( fileItem.willAutoSummarize && !fileItem.hasCheckedForAutoSummary ) { fileItem.hasCheckedForAutoSummary = true; fileItem.autoSummaryStartTime = Date.now(); console.log( `Auto-summary expected for ${fileItem.file.name}, continuing to poll...` ); // Don't complete yet, continue polling return; } // If we have auto-summarization and we've been waiting, check if we should timeout if ( fileItem.willAutoSummarize && fileItem.hasCheckedForAutoSummary ) { const waitTime = Date.now() - fileItem.autoSummaryStartTime; const maxWaitTime = 60000; // 60 seconds if (waitTime > maxWaitTime) { // Timeout - complete the process console.log( `Auto-summary timeout for ${fileItem.file.name}, completing...` ); processingMessage.value = "Processing complete!"; processingProgress.value = 100; fileItem.status = "completed"; fileItem.summaryCompleted = true; // Mark as complete due to timeout clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; // Keep completed items visible in the modal - don't remove them console.log( `Timed-out item ${fileItem.clientId} will remain visible in queue` ); startProcessingQueue(); } else { // Still waiting for auto-summary, continue polling return; } } // Normal completion path (no auto-summary check needed) clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; // Remove this item from uploadQueue immediately to prevent duplication const queueIndex = uploadQueue.value.findIndex( (item) => item.clientId === fileItem.clientId ); if (queueIndex !== -1) { uploadQueue.value.splice(queueIndex, 1); console.log( `Removed completed item ${fileItem.clientId} from queue immediately` ); } startProcessingQueue(); } else if (data.status === "FAILED") { console.log( `Processing FAILED for ${fileItem.file.name} (ID: ${recordingId})` ); processingMessage.value = "Processing failed."; processingProgress.value = 100; fileItem.status = "failed"; fileItem.error = data.transcription || data.summary || "Processing failed on server."; setGlobalError( `Processing failed for "${data.title || fileItem.file.name}".` ); clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; await nextTick(); startProcessingQueue(); } else if (data.status === "PROCESSING") { // Check if this file will actually use chunking based on all conditions: // 1. Chunking must be enabled in config // 2. Must NOT be using ASR endpoint (ASR handles large files natively) // 3. For size-based: File size must exceed the limit (can determine immediately) // 4. For time-based: Can't determine client-side, but backend logs show it gets duration const couldUseChunking = chunkingEnabled.value && !useAsrEndpoint.value; if (couldUseChunking) { if (chunkingMode.value === "size") { // Size-based chunking: we can determine definitively const chunkThresholdBytes = chunkingLimit.value * 1024 * 1024; const willUseChunking = fileItem.file.size > chunkThresholdBytes; if (willUseChunking) { processingMessage.value = "Processing large file (chunking in progress)..."; // If auto-summarization will occur, cap at 70%, otherwise 80% const maxProgress = fileItem.willAutoSummarize ? 70 : 80; processingProgress.value = Math.round( Math.min( maxProgress, processingProgress.value + Math.random() * 3 ) ); } else { processingMessage.value = "转录进行中..."; // If auto-summarization will occur, cap at 65%, otherwise 75% const maxProgress = fileItem.willAutoSummarize ? 65 : 75; processingProgress.value = Math.round( Math.min( maxProgress, processingProgress.value + Math.random() * 5 ) ); } } else { // Duration-based chunking: Backend determines this after getting duration // Show a neutral processing message since we can't know client-side processingMessage.value = "Processing file (chunking determined server-side)..."; const maxProgress = fileItem.willAutoSummarize ? 70 : 80; processingProgress.value = Math.round( Math.min( maxProgress, processingProgress.value + Math.random() * 3 ) ); } } else { processingMessage.value = "转录进行中..."; const maxProgress = fileItem.willAutoSummarize ? 65 : 75; processingProgress.value = Math.round( Math.min( maxProgress, processingProgress.value + Math.random() * 5 ) ); } } else if (data.status === "SUMMARIZING") { console.log(`Auto-summary started for ${fileItem.file.name}`); processingMessage.value = "Generating summary..."; processingProgress.value = 90; fileItem.status = "summarizing"; } else { processingMessage.value = "Waiting in queue..."; processingProgress.value = 45; } } catch (error) { console.error( `Polling Error for ${fileItem.file.name} (ID: ${recordingId}):`, error ); fileItem.status = "failed"; fileItem.error = `Error checking status: ${error.message}`; setGlobalError( `Error checking status for "${fileItem.file.name}": ${error.message}.` ); const galleryIndex = recordings.value.findIndex( (r) => r.id === recordingId ); if (galleryIndex !== -1) recordings.value[galleryIndex].status = "FAILED"; clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; await nextTick(); startProcessingQueue(); } }, 5000); }; // --- Data Loading --- const loadRecordings = async ( page = 1, append = false, searchQuery = "" ) => { globalError.value = null; if (!append) { isLoadingRecordings.value = true; } else { isLoadingMore.value = true; } try { const params = new URLSearchParams({ page: page.toString(), per_page: perPage.value.toString(), }); if (searchQuery.trim()) { params.set("q", searchQuery.trim()); } const response = await fetch(`/tool/speakr/api/recordings?${params}`); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to load recordings"); // Update pagination state currentPage.value = data.pagination.page; totalRecordings.value = data.pagination.total; totalPages.value = data.pagination.total_pages; hasNextPage.value = data.pagination.has_next; hasPrevPage.value = data.pagination.has_prev; // Update recordings data if (append) { // Append to existing recordings (infinite scroll) recordings.value = [...recordings.value, ...data.recordings]; } else { // Replace recordings (fresh load or search) recordings.value = data.recordings; // Try to restore last selected recording const lastRecordingId = localStorage.getItem( "lastSelectedRecordingId" ); if (lastRecordingId && data.recordings.length > 0) { const recordingToSelect = data.recordings.find( (r) => r.id == lastRecordingId ); if (recordingToSelect) { selectRecording(recordingToSelect); } } } // Handle incomplete recordings for processing queue const incompleteRecordings = data.recordings.filter((r) => ["PENDING", "PROCESSING", "SUMMARIZING"].includes(r.status) ); if (incompleteRecordings.length > 0 && !isProcessingActive.value) { console.warn( `Found ${incompleteRecordings.length} incomplete recording(s) on load.` ); for (const recording of incompleteRecordings) { let queueItem = uploadQueue.value.find( (item) => item.recordingId === recording.id ); if (!queueItem) { queueItem = { file: { name: recording.title || `Recording ${recording.id}`, size: recording.file_size, }, status: "queued", recordingId: recording.id, clientId: `reload-${recording.id}`, error: null, }; uploadQueue.value.unshift(queueItem); if (!isProcessingActive.value) { startProcessingQueue(); } } } } } catch (error) { console.error("Load Recordings Error:", error); setGlobalError(`Failed to load recordings: ${error.message}`); if (!append) { recordings.value = []; } } finally { isLoadingRecordings.value = false; isLoadingMore.value = false; } }; // Load more recordings (infinite scroll) const loadMoreRecordings = async () => { if (!hasNextPage.value || isLoadingMore.value) return; await loadRecordings(currentPage.value + 1, true, searchQuery.value); }; // Search with debouncing const performSearch = async (query = "") => { currentPage.value = 1; await loadRecordings(1, false, query); }; // Debounced search function const debouncedSearch = (query) => { if (searchDebounceTimer.value) { clearTimeout(searchDebounceTimer.value); } searchDebounceTimer.value = setTimeout(() => { performSearch(query); }, 300); // 300ms debounce }; const loadTags = async () => { try { const response = await fetch("/tool/speakr/api/tags"); if (response.ok) { availableTags.value = await response.json(); } else { console.warn("Failed to load tags:", response.status); availableTags.value = []; } } catch (error) { console.warn("Error loading tags:", error); availableTags.value = []; } }; const addTagToSelection = (tagId) => { if (!selectedTagIds.value.includes(tagId)) { selectedTagIds.value.push(tagId); applyTagDefaults(); } }; const removeTagFromSelection = (tagId) => { const index = selectedTagIds.value.indexOf(tagId); if (index > -1) { selectedTagIds.value.splice(index, 1); applyTagDefaults(); } }; const applyTagDefaults = () => { // Apply defaults from the first selected tag (highest priority) const firstTag = selectedTags.value[0]; if (firstTag && useAsrEndpoint.value) { if (firstTag.default_language) { uploadLanguage.value = firstTag.default_language; } if (firstTag.default_min_speakers) { uploadMinSpeakers.value = firstTag.default_min_speakers; } if (firstTag.default_max_speakers) { uploadMaxSpeakers.value = firstTag.default_max_speakers; } } }; // Legacy function for backward compatibility const onTagSelected = applyTagDefaults; // Tag helper functions const getRecordingTags = (recording) => { if (!recording || !recording.tags) return []; return recording.tags || []; }; const getAvailableTagsForRecording = (recording) => { if (!recording || !availableTags.value) return []; const recordingTagIds = getRecordingTags(recording).map( (tag) => tag.id ); return availableTags.value.filter( (tag) => !recordingTagIds.includes(tag.id) ); }; // Computed property for filtered available tags in the modal const filteredAvailableTagsForModal = computed(() => { if (!editingRecording.value) return []; const availableTags = getAvailableTagsForRecording( editingRecording.value ); if (!tagSearchFilter.value) return availableTags; const filter = tagSearchFilter.value.toLowerCase(); return availableTags.filter((tag) => tag.name.toLowerCase().includes(filter) ); }); const filterByTag = (tag) => { // Use advanced filter instead of text-based filterTags.value = [tag.id]; applyAdvancedFilters(); }; const clearTagFilter = () => { searchQuery.value = ""; clearAllFilters(); }; // Build search query from advanced filters const buildSearchQuery = () => { let query = []; // Add text search if (filterTextQuery.value.trim()) { query.push(filterTextQuery.value.trim()); } // Add tag filters if (filterTags.value.length > 0) { const tagNames = filterTags.value .map((tagId) => { const tag = availableTags.value.find((t) => t.id === tagId); return tag ? `tag:${tag.name.replace(/\s+/g, "_")}` : ""; }) .filter(Boolean); query.push(...tagNames); } // Add date filter if (filterDatePreset.value) { query.push(`date:${filterDatePreset.value}`); } else if (filterDateRange.value.start || filterDateRange.value.end) { // Custom date range - send as separate parameters // Will be handled differently in the backend if (filterDateRange.value.start) { query.push(`date_from:${filterDateRange.value.start}`); } if (filterDateRange.value.end) { query.push(`date_to:${filterDateRange.value.end}`); } } return query.join(" "); }; const applyAdvancedFilters = () => { searchQuery.value = buildSearchQuery(); }; const clearAllFilters = () => { filterTags.value = []; filterDateRange.value = { start: "", end: "" }; filterDatePreset.value = ""; filterTextQuery.value = ""; searchQuery.value = ""; }; const editRecordingTags = (recording) => { editingRecording.value = recording; selectedNewTagId.value = ""; showEditTagsModal.value = true; }; const closeEditTagsModal = () => { showEditTagsModal.value = false; editingRecording.value = null; selectedNewTagId.value = ""; tagSearchFilter.value = ""; // Clear the filter when closing }; const addTagToRecording = async (tagId = null) => { // Use provided tagId or fall back to selectedNewTagId const tagToAddId = tagId || selectedNewTagId.value; if (!tagToAddId || !editingRecording.value) return; try { const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute("content"); const response = await fetch( `/tool/speakr/api/recordings/${editingRecording.value.id}/tags`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken, }, body: JSON.stringify({ tag_id: tagToAddId }), } ); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to add tag"); } // Update local recording data const tagToAdd = availableTags.value.find( (tag) => tag.id == tagToAddId ); if (tagToAdd) { if (!editingRecording.value.tags) { editingRecording.value.tags = []; } editingRecording.value.tags.push(tagToAdd); // Also update in recordings list if it's a different object const recordingInList = recordings.value.find( (r) => r.id === editingRecording.value.id ); if (recordingInList && recordingInList !== editingRecording.value) { if (!recordingInList.tags) { recordingInList.tags = []; } recordingInList.tags.push(tagToAdd); } } selectedNewTagId.value = ""; } catch (error) { console.error("Error adding tag to recording:", error); setGlobalError(`Failed to add tag: ${error.message}`); } }; const removeTagFromRecording = async (tagId) => { if (!editingRecording.value) return; try { const csrfToken = document .querySelector('meta[name="csrf-token"]') ?.getAttribute("content"); const response = await fetch( `/tool/speakr/api/recordings/${editingRecording.value.id}/tags/${tagId}`, { method: "DELETE", headers: { "X-CSRFToken": csrfToken, }, } ); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to remove tag"); } // Update local recording data editingRecording.value.tags = editingRecording.value.tags.filter( (tag) => tag.id !== tagId ); // Also update in recordings list if it's a different object const recordingInList = recordings.value.find( (r) => r.id === editingRecording.value.id ); if ( recordingInList && recordingInList !== editingRecording.value && recordingInList.tags ) { recordingInList.tags = recordingInList.tags.filter( (tag) => tag.id !== tagId ); } } catch (error) { console.error("Error removing tag from recording:", error); setGlobalError(`Failed to remove tag: ${error.message}`); } }; // --- Audio Recording --- const startRecordingWithDisclaimer = async (mode = "microphone") => { // Check if disclaimer needs to be shown if (recordingDisclaimer.value && recordingDisclaimer.value.trim()) { pendingRecordingMode.value = mode; showRecordingDisclaimerModal.value = true; } else { // No disclaimer configured, proceed directly await startRecordingActual(mode); } }; const acceptRecordingDisclaimer = async () => { showRecordingDisclaimerModal.value = false; if (pendingRecordingMode.value) { await startRecordingActual(pendingRecordingMode.value); pendingRecordingMode.value = null; } }; const cancelRecordingDisclaimer = () => { showRecordingDisclaimerModal.value = false; pendingRecordingMode.value = null; }; const startRecording = startRecordingWithDisclaimer; // Maintain backward compatibility let systemStream = null; let micStream = null; const startRecordingActual = async (mode = "microphone") => { recordingMode.value = mode; try { // Load tags if not already loaded if (availableTags.value.length === 0) { await loadTags(); } // Reset state audioChunks.value = []; audioBlobURL.value = null; recordingNotes.value = ""; activeStreams.value = []; // Clear previous tag selection and ASR options for fresh recording selectedTags.value = []; asrLanguage.value = ""; asrMinSpeakers.value = ""; asrMaxSpeakers.value = ""; // Reset draft-related state for fresh recording (Bug fix: prevents new recordings from being merged into old drafts) currentDraftId.value = null; isPaused.value = false; pausedDuration.value = 0; pausedTranscription.value = ''; segmentList = []; segmentRenderStartIndex = 0; asrResult.value = ''; asrResultOnline.value = ''; asrResultOffline.value = ''; recordingTime.value = 0; isStoppingRecording.value = false; pendingCorrectionCount = 0; isEditingTranscription.value = false; startNewRealtimeEpoch({ clearTimestampBarrier: true }); let combinedStream = null; // Get microphone stream if needed if (mode === "microphone" || mode === "both") { if (!canRecordAudio.value) { throw new Error( "Microphone recording is not supported by your browser or permission was denied." ); } micStream = await navigator.mediaDevices.getUserMedia({ audio: true, }); activeStreams.value.push(micStream); showToast("Microphone access granted", "fa-microphone"); } // Get system audio stream if needed if (mode === "system" || mode === "both") { if (!canRecordSystemAudio.value) { throw new Error( "System audio recording is not supported by your browser." ); } try { systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true, // Request video to enable system audio sharing prompt }); // Check if the user actually granted audio permission if (systemStream.getAudioTracks().length === 0) { // Stop the video track if it exists, since we didn't get audio systemStream.getVideoTracks().forEach((track) => track.stop()); throw new Error( 'System audio permission was not granted. Please ensure you check the "Share system audio" box.' ); } activeStreams.value.push(systemStream); showToast("System audio access granted", "fa-desktop"); } catch (err) { if (mode === "system") { throw err; // Re-throw the original error to be caught by the outer handler } else { // For 'both' mode, fall back to microphone only showToast( "System audio denied, using microphone only", "fa-exclamation-triangle" ); mode = "microphone"; systemStream = null; // Make sure systemStream is null so it's not used later } } } // Combine streams if we have both if (micStream && systemStream) { try { audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); const micSource = audioContext.value.createMediaStreamSource(micStream); const systemSource = audioContext.value.createMediaStreamSource(systemStream); const destination = audioContext.value.createMediaStreamDestination(); micSource.connect(destination); systemSource.connect(destination); // Create a new MediaStream with only the audio track from the destination const mixedAudioTrack = destination.stream.getAudioTracks()[0]; if (!mixedAudioTrack) { throw new Error("Failed to create mixed audio track"); } combinedStream = new MediaStream([mixedAudioTrack]); // Verify the stream has audio tracks if (combinedStream.getAudioTracks().length === 0) { throw new Error("Combined stream has no audio tracks"); } console.log( "Successfully created combined audio stream with", combinedStream.getAudioTracks().length, "audio tracks" ); showToast( "Recording both microphone and system audio", "fa-microphone" ); } catch (error) { console.error("Failed to combine audio streams:", error); // Fallback to system audio only if (audioContext.value) { audioContext.value .close() .catch((e) => console.error("Error closing AudioContext:", e) ); audioContext.value = null; } combinedStream = systemStream; showToast( "Failed to combine audio, using system audio only", "fa-exclamation-triangle" ); } } else if (systemStream) { // For system audio only, create a new stream with just the audio tracks const audioTracks = systemStream.getAudioTracks(); if (audioTracks.length > 0) { combinedStream = new MediaStream(audioTracks); console.log( "Created system audio stream with", audioTracks.length, "audio tracks" ); showToast("Recording system audio only", "fa-desktop"); } else { throw new Error("System stream has no audio tracks"); } } else if (micStream) { combinedStream = micStream; showToast("Recording microphone only", "fa-microphone"); } else { throw new Error("No audio streams available for recording."); } // Setup MediaRecorder with optimized settings for transcription const getOptimizedRecorderOptions = () => { // Define transcription-optimized options in order of preference const optionsList = [ // Best option: Opus codec at 32kbps (excellent compression for speech) { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 32000, description: "Optimized (32kbps Opus)", }, // Good option: Opus at 64kbps (slightly higher quality) { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 64000, description: "Good quality (64kbps Opus)", }, // Fallback 1: WebM with reduced bitrate { mimeType: "audio/webm", audioBitsPerSecond: 64000, description: "Standard WebM (64kbps)", }, // Fallback 2: MP4 with reduced bitrate { mimeType: "audio/mp4", audioBitsPerSecond: 64000, description: "Standard MP4 (64kbps)", }, // Fallback 3: Just the codec without bitrate { mimeType: "audio/webm;codecs=opus", description: "Opus codec (default bitrate)", }, // Fallback 4: Just WebM without bitrate { mimeType: "audio/webm", description: "WebM (default bitrate)", }, ]; // Test each option to find the first supported one for (const options of optionsList) { if (MediaRecorder.isTypeSupported(options.mimeType)) { console.log( `Testing audio recording option: ${options.description} - ${options.mimeType}` ); return options; } } // Final fallback: no options (browser default) console.log("Using browser default audio recording settings"); return null; }; // Try to create MediaRecorder with progressive fallbacks let mediaRecorderCreated = false; let recorderOptions = getOptimizedRecorderOptions(); let attemptCount = 0; while (!mediaRecorderCreated && attemptCount < 5) { try { attemptCount++; if (recorderOptions && attemptCount === 1) { // First attempt: try with full options console.log( `Attempt ${attemptCount}: Trying ${recorderOptions.description}` ); mediaRecorder.value = new MediaRecorder( combinedStream, recorderOptions ); actualBitrate.value = recorderOptions.audioBitsPerSecond || 64000; showToast( `Recording: ${recorderOptions.description}`, "fa-compress-alt", 3000 ); } else if ( recorderOptions && attemptCount === 2 && recorderOptions.audioBitsPerSecond ) { // Second attempt: try same mime type without bitrate constraint console.log( `Attempt ${attemptCount}: Trying ${recorderOptions.mimeType} without bitrate` ); mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: recorderOptions.mimeType, }); actualBitrate.value = 128000; // Estimate showToast( `Recording: ${recorderOptions.mimeType} (default bitrate)`, "fa-compress-alt", 3000 ); } else { // Final attempt: browser default console.log(`Attempt ${attemptCount}: Using browser default`); mediaRecorder.value = new MediaRecorder(combinedStream); actualBitrate.value = 128000; // Estimate browser default showToast( "Recording with browser default settings", "fa-microphone", 3000 ); } mediaRecorderCreated = true; console.log( `MediaRecorder created successfully on attempt ${attemptCount}` ); } catch (error) { console.warn( `MediaRecorder creation attempt ${attemptCount} failed:`, error ); mediaRecorder.value = null; if (attemptCount >= 5) { throw new Error( `Failed to create MediaRecorder after ${attemptCount} attempts. Last error: ${error.message}` ); } } } if (!mediaRecorder.value) { throw new Error( "Failed to create MediaRecorder with any configuration" ); } console.log( `Recording with estimated bitrate: ${actualBitrate.value} bps` ); mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data); mediaRecorder.value.onstop = () => { const audioBlob = new Blob(audioChunks.value, { type: "audio/webm", }); audioBlobURL.value = URL.createObjectURL(audioBlob); // Stop size monitoring stopSizeMonitoring(); // Stop all active streams activeStreams.value.forEach((stream) => { stream.getTracks().forEach((track) => track.stop()); }); activeStreams.value = []; if (audioContext.value) { audioContext.value .close() .catch((e) => console.error("Error closing AudioContext:", e)); audioContext.value = null; } cancelAnimationFrame(animationFrameId.value); clearInterval(recordingInterval.value); }; // --- Visualizer Setup --- if (!audioContext.value) { audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); } if (mode === "both" && micStream && systemStream) { // Dual visualizer setup micAnalyser.value = audioContext.value.createAnalyser(); micAnalyser.value.fftSize = 256; const micSource = audioContext.value.createMediaStreamSource(micStream); micSource.connect(micAnalyser.value); systemAnalyser.value = audioContext.value.createAnalyser(); systemAnalyser.value.fftSize = 256; const systemSource = audioContext.value.createMediaStreamSource(systemStream); systemSource.connect(systemAnalyser.value); } else { // Single visualizer setup const visualizerStream = micStream || systemStream; if (visualizerStream) { analyser.value = audioContext.value.createAnalyser(); analyser.value.fftSize = 256; const source = audioContext.value.createMediaStreamSource(visualizerStream); source.connect(analyser.value); } } // Start recording and timer mediaRecorder.value.start(); isRecording.value = true; recordingTime.value = 0; recordingInterval.value = setInterval( () => recordingTime.value++, 1000 ); // Start size monitoring startSizeMonitoring(); // Switch to recording view currentView.value = "recording"; // Start visualizer(s) drawVisualizers(); setGlobalError(null); } catch (err) { console.error("Error starting recording:", err); setGlobalError(`Could not start recording: ${err.message}`); isRecording.value = false; // Clean up any streams that were created activeStreams.value.forEach((stream) => { stream.getTracks().forEach((track) => track.stop()); }); activeStreams.value = []; } }; const stopRecording = async () => { if (isStoppingRecording.value) { return; } const hasRecordingSession = isRecording.value || isPaused.value || audioChunks.value.length > 0 || !!currentDraftId.value || activeStreams.value.length > 0; if (!hasRecordingSession) { return; } isStoppingRecording.value = true; try { isRec = false; if (mediaRecorder.value && isRecording.value) { is_final.value = true; mediaRecorder.value.stop(); isRecording.value = false; stopSizeMonitoring(); cancelAnimationFrame(animationFrameId.value); animationFrameId.value = null; if (recordingInterval.value) { clearInterval(recordingInterval.value); recordingInterval.value = null; } } const finishPromises = []; if (webReader && typeof webReader.wsFinish === "function") { finishPromises.push(webReader.wsFinish(3500)); } else if (webReader) { webReader.wsStop(); } if (webReader2 && typeof webReader2.wsFinish === "function") { finishPromises.push(webReader2.wsFinish(3500)); } else if (webReader2) { webReader2.wsStop(); } if (finishPromises.length > 0) { await Promise.allSettled(finishPromises); } // Wait for MediaRecorder to emit the final blob. await new Promise(resolve => setTimeout(resolve, 500)); if (currentDraftId.value) { try { // 上传最后一个片段 (only if not already paused/uploaded) if (!isPaused.value && audioChunks.value.length > 0) { const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' }); const formData = new FormData(); formData.append('audio', audioBlob); formData.append('duration', recordingTime.value - pausedDuration.value); formData.append('transcription', asrResult.value + asrResultOffline.value + asrResultOnline.value || ''); await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, { method: 'POST', body: formData }); // 清理 audioChunks 防止重复 audioChunks.value = []; } // 合并音频片段并获取预览 URL (不 finalize,只是下载合并后的音频用于预览) const audioResp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/download`); if (audioResp.ok) { const audioBlob = await audioResp.blob(); audioBlobURL.value = URL.createObjectURL(audioBlob); } // 保持 currentDraftId 状态,以便用户选择上传或放弃 isPaused.value = false; } catch (err) { console.error('Error saving final segment:', err); setGlobalError(`保存片段失败: ${err.message}`); } } else { // 非草稿模式(直接录音),创建 audioBlobURL if (audioChunks.value.length > 0) { const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' }); audioBlobURL.value = URL.createObjectURL(audioBlob); } } if (isMobileScreen.value) { isSidebarCollapsed.value = true; } } finally { isStoppingRecording.value = false; } }; // 暂停录音 const pauseRecording = async () => { if (!isRecording.value || isPaused.value) return; try { // 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据 isRec = false; // 停止 WebSocket 转录 if (webReader) webReader.wsStop(); if (webReader2) webReader2.wsStop(); // 停止当前 MediaRecorder if (mediaRecorder.value && mediaRecorder.value.state === 'recording') { mediaRecorder.value.stop(); } // 等待 MediaRecorder 生成最终数据 await new Promise(resolve => setTimeout(resolve, 500)); // 如果处于编辑模式,先退出编辑模式 if (isEditingTranscription.value) { exitEditMode(); } // 等待所有进行中的LLM矫正完成(最多8秒超时) if (pendingCorrectionCount > 0) { console.log(`等待 ${pendingCorrectionCount} 个LLM矫正完成...`); const waitStart = Date.now(); while (pendingCorrectionCount > 0 && Date.now() - waitStart < 8000) { await new Promise(resolve => setTimeout(resolve, 200)); } if (pendingCorrectionCount > 0) { console.warn(`超时:仍有 ${pendingCorrectionCount} 个LLM矫正未完成`); } } // 创建草稿(如果是第一次暂停) if (!currentDraftId.value) { const resp = await fetch('/tool/speakr/api/draft/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recording_mode: recordingMode.value, notes: recordingNotes.value }) }); const data = await resp.json(); if (data.success) { currentDraftId.value = data.draft.id; } else { throw new Error(data.error || '创建草稿失败'); } } // 上传当前片段 if (audioChunks.value.length > 0) { const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' }); const formData = new FormData(); formData.append('audio', audioBlob); formData.append('duration', recordingTime.value - pausedDuration.value); // 发送完整转录文本(历史 + 离线修正 + 当前在线结果)以防止覆盖历史记录 const fullTranscription = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || ''); formData.append('transcription', fullTranscription); await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/segment`, { method: 'POST', body: formData }); // Clear chunks after upload to prevent duplication audioChunks.value = []; } // 更新状态 isPaused.value = true; pausedDuration.value = recordingTime.value; pausedTranscription.value = (asrResult.value || '') + (asrResultOffline.value || '') + (asrResultOnline.value || ''); isRecording.value = false; // 停止计时器和动画 clearInterval(recordingInterval.value); cancelAnimationFrame(animationFrameId.value); stopSizeMonitoring(); // 停止所有媒体流 activeStreams.value.forEach(stream => { stream.getTracks().forEach(track => track.stop()); }); activeStreams.value = []; micStream = null; systemStream = null; showToast('录音已暂停', 'fa-pause'); // 刷新草稿列表 loadDrafts(); } catch (err) { console.error('Error pausing recording:', err); setGlobalError(`暂停录音失败: ${err.message}`); } }; // 恢复录音 const resumeRecording = async () => { if (!isPaused.value || !currentDraftId.value) return; try { // 重置音频块 audioChunks.value = []; // Bug fix: 清空segmentList,防止旧segment与pausedTranscription(历史文本)重复渲染 // pausedTranscription已经包含了历史转录,segmentList应该只包含恢复后的新转录 segmentList = []; segmentRenderStartIndex = 0; pendingCorrectionCount = 0; isEditingTranscription.value = false; startNewRealtimeEpoch({ clearTimestampBarrier: true }); let combinedStream = null; const mode = recordingMode.value; // 重新获取媒体流 if (mode === 'microphone' || mode === 'both') { micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); activeStreams.value.push(micStream); } if (mode === 'system' || mode === 'both') { try { systemStream = await navigator.mediaDevices.getDisplayMedia({ audio: true, video: true }); if (systemStream.getAudioTracks().length === 0) { systemStream.getVideoTracks().forEach(track => track.stop()); throw new Error('未获取系统音频权限'); } activeStreams.value.push(systemStream); } catch (err) { if (mode === 'system') throw err; showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle'); recordingMode.value = 'microphone'; } } // 组合音频流 if (micStream && systemStream) { audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); const micSource = audioContext.value.createMediaStreamSource(micStream); const systemSource = audioContext.value.createMediaStreamSource(systemStream); const destination = audioContext.value.createMediaStreamDestination(); micSource.connect(destination); systemSource.connect(destination); combinedStream = new MediaStream([destination.stream.getAudioTracks()[0]]); } else if (systemStream) { combinedStream = new MediaStream(systemStream.getAudioTracks()); } else if (micStream) { combinedStream = micStream; } else { throw new Error('无可用音频流'); } // 创建新的 MediaRecorder mediaRecorder.value = new MediaRecorder(combinedStream, { mimeType: 'audio/webm;codecs=opus' }); mediaRecorder.value.ondataavailable = (event) => audioChunks.value.push(event.data); mediaRecorder.value.onstop = () => { const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' }); audioBlobURL.value = URL.createObjectURL(audioBlob); }; // 重新设置可视化 if (!audioContext.value) { audioContext.value = new (window.AudioContext || window.webkitAudioContext)(); } if (mode === 'both' && micStream && systemStream) { // Dual visualizer setup micAnalyser.value = audioContext.value.createAnalyser(); micAnalyser.value.fftSize = 256; const micSource = audioContext.value.createMediaStreamSource(micStream); micSource.connect(micAnalyser.value); systemAnalyser.value = audioContext.value.createAnalyser(); systemAnalyser.value.fftSize = 256; const systemSource = audioContext.value.createMediaStreamSource(systemStream); systemSource.connect(systemAnalyser.value); } else { const visualizerStream = micStream || systemStream; if (visualizerStream) { analyser.value = audioContext.value.createAnalyser(); analyser.value.fftSize = 256; const source = audioContext.value.createMediaStreamSource(visualizerStream); source.connect(analyser.value); } } // 开始录音 mediaRecorder.value.start(); isPaused.value = false; isRecording.value = true; // 恢复计时器(从暂停时间继续) recordingInterval.value = setInterval(() => recordingTime.value++, 1000); // 恢复可视化 drawVisualizers(); startSizeMonitoring(); // 恢复 WebSocket 转录 console.log("Resuming WebSocket connections..."); // 设置重置标志,清空实时转录显示 resetTranscriptionFlag = true; asrResultOnline.value = ''; if (webReader) { const ret = webReader.wsStart(); if (ret === 1) { isRec = true; } } // offline/2pass if (webReader2) { try { webReader2.wsStart("offline"); isRec = true; } catch(e) { console.warn("Failed to restart offline WS:", e); } } showToast('录音已恢复', 'fa-play'); } catch (err) { console.error('Error resuming recording:', err); setGlobalError(`恢复录音失败: ${err.message}`); } }; // 加载草稿列表 const loadDrafts = async () => { try { const resp = await fetch('/tool/speakr/api/draft/list'); const data = await resp.json(); draftRecordings.value = Array.isArray(data) ? data : []; } catch (err) { console.error('Error loading drafts:', err); } }; // 继续草稿录音 const continueDraftRecording = async (draftId) => { try { // 获取草稿详情 const resp = await fetch(`/tool/speakr/api/draft/${draftId}`); const draft = await resp.json(); if (draft.error) { throw new Error(draft.error); } // 恢复状态 currentDraftId.value = draftId; recordingMode.value = draft.recording_mode || 'microphone'; recordingTime.value = Math.round(draft.total_duration || 0); pausedDuration.value = recordingTime.value; recordingNotes.value = draft.notes || ''; // Load historical transcription into asrResult console.log('Loading draft transcription:', draft.combined_transcription ? draft.combined_transcription.length : 0, 'chars'); asrResult.value = draft.combined_transcription || ''; asrResultOnline.value = ''; asrResultOffline.value = ''; pausedTranscription.value = draft.combined_transcription || ''; segmentRenderStartIndex = 0; // 设置暂停状态 isPaused.value = true; isRecording.value = false; // 切换到录音视图 currentView.value = 'recording'; showToast('草稿已加载,点击恢复继续录音', 'fa-folder-open'); } catch (err) { console.error('Error continuing draft:', err); setGlobalError(`加载草稿失败: ${err.message}`); } }; // 删除草稿 const deleteDraft = async (draftId) => { if (!confirm('确定要删除这个草稿吗?此操作不可撤销。')) return false; try { const resp = await fetch(`/tool/speakr/api/draft/${draftId}`, { method: 'DELETE' }); const data = await resp.json(); if (data.success) { showToast('草稿已删除', 'fa-trash'); loadDrafts(); // 如果删除的是当前草稿,重置状态 if (currentDraftId.value === draftId) { currentDraftId.value = null; isPaused.value = false; pausedDuration.value = 0; pausedTranscription.value = ''; segmentRenderStartIndex = 0; } return true; } else { throw new Error(data.error); } } catch (err) { console.error('Error deleting draft:', err); setGlobalError(`删除草稿失败: ${err.message}`); return false; } }; // 重命名草稿 const startDraftRename = (draft) => { editingDraftId.value = draft.id; editingDraftName.value = draft.name; // 等待 DOM 更新后聚焦输入框 nextTick(() => { if (draftNameInput.value && draftNameInput.value[0]) { draftNameInput.value[0].focus(); } else if (draftNameInput.value) { draftNameInput.value.focus(); } }); }; const saveDraftRename = async (draft) => { if (!editingDraftId.value) return; const newName = editingDraftName.value.trim(); editingDraftId.value = null; // 退出编辑模式 if (!newName || newName === draft.name) return; try { const resp = await fetch(`/tool/speakr/api/draft/${draft.id}/rename`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); const data = await resp.json(); if (data.success) { draft.name = newName; showToast('重命名成功', 'fa-check'); } else { throw new Error(data.error); } } catch (err) { console.error('Error renaming draft:', err); showToast(`重命名失败: ${err.message}`, 'fa-exclamation-circle'); } }; const uploadRecordedAudio = async () => { // 如果有草稿,先获取合并后的音频文件,然后走统一上传流程 if (currentDraftId.value) { try { showToast('Finalizing draft...', 'fa-spinner fa-spin', 0); const savedNotes = recordingNotes.value; const savedTags = [...selectedTags.value]; const savedAsrOptions = { language: asrLanguage.value, min_speakers: asrMinSpeakers.value, max_speakers: asrMaxSpeakers.value, }; const resp = await fetch(`/tool/speakr/api/draft/${currentDraftId.value}/finalize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes: savedNotes, tag_ids: savedTags.map((tag) => tag.id || tag), ...savedAsrOptions, }), }); const data = await resp.json(); if (!resp.ok || !data.success || !data.recording) { throw new Error(data.error || 'Finalize draft failed'); } const recording = data.recording; const existingIndex = recordings.value.findIndex((r) => r.id === recording.id); if (existingIndex === -1) { recordings.value.unshift(recording); totalRecordings.value += 1; } else { recordings.value[existingIndex] = recording; } const queueItem = { file: { name: recording.original_filename || recording.title || `draft-${recording.id}.webm`, size: recording.file_size || 0, }, notes: savedNotes, tags: savedTags, asrOptions: savedAsrOptions, status: 'queued', recordingId: recording.id, clientId: `reload-draft-${recording.id}`, error: null, willAutoSummarize: true, }; uploadQueue.value.push(queueItem); progressPopupMinimized.value = false; progressPopupClosed.value = false; currentDraftId.value = null; pausedDuration.value = 0; pausedTranscription.value = ''; segmentRenderStartIndex = 0; loadDrafts(); if (audioBlobURL.value) { URL.revokeObjectURL(audioBlobURL.value); } audioBlobURL.value = null; audioChunks.value = []; isRecording.value = false; recordingNotes.value = ''; selectedTagIds.value = []; asrLanguage.value = ''; asrMinSpeakers.value = ''; asrMaxSpeakers.value = ''; recordingTime.value = 0; currentView.value = 'upload'; selectedRecording.value = null; if (!isProcessingActive.value) { startProcessingQueue(); } showToast('Draft submitted for processing', 'fa-check-circle'); } catch (err) { console.error('Error finalizing draft:', err); setGlobalError(`Upload failed: ${err.message}`); } return; } if (!audioBlobURL.value) { setGlobalError("No recorded audio to upload."); return; } const previousQueueLength = uploadQueue.value.length; const recordedFile = new File( audioChunks.value, createLocalizedRecordingFilename(), { type: "audio/webm" } ); addFilesToQueue([ { file: recordedFile, notes: recordingNotes.value, tags: selectedTags.value, asrOptions: { language: asrLanguage.value, min_speakers: asrMinSpeakers.value, max_speakers: asrMaxSpeakers.value, }, }, ]); if (uploadQueue.value.length === previousQueueLength) { return; } const queueItem = uploadQueue.value[uploadQueue.value.length - 1]; showToast("正在上传录音至服务器,请稍候...", "fa-spinner fa-spin", 0); const waitForServerResponse = () => { return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { if (!queueItem || queueItem.status === 'failed') { clearInterval(checkInterval); reject(new Error(queueItem?.error || "Upload failed")); return; } const successStates = ['pending', 'processing', 'transcribing', 'summarizing', 'completed']; if (successStates.includes(queueItem.status)) { clearInterval(checkInterval); resolve(true); } }, 200); }); }; try { await waitForServerResponse(); discardRecording(); if (isRec === true) { if (typeof stopRecording === 'function') stopRecording(); } currentView.value = "upload"; selectedRecording.value = null; if (isMobileScreen.value) { isSidebarCollapsed.value = true; } showToast("上传成功,正在处理中...", "fa-check-circle"); } catch (error) { console.error("Server upload failed:", error); showToast(`上传失败: ${error.message || '请检查网络'}`, "fa-exclamation-triangle", 4000); } }; const downloadRecordedAudio = () => { if (!audioBlobURL.value) { showToast("No recording available to download", "fa-exclamation-circle"); return; } const filename = createLocalizedRecordingFilename(); const a = document.createElement('a'); a.style.display = 'none'; a.href = audioBlobURL.value; a.download = filename; document.body.appendChild(a); a.click(); // 清理 setTimeout(() => { document.body.removeChild(a); }, 100); showToast("录音已开始下载", "fa-download"); }; const discardRecording = async () => { if (currentDraftId.value) { const deleted = await deleteDraft(currentDraftId.value); if (!deleted) return; } if (audioBlobURL.value) { URL.revokeObjectURL(audioBlobURL.value); } audioBlobURL.value = null; audioChunks.value = []; isRecording.value = false; recordingTime.value = 0; if (recordingInterval.value) clearInterval(recordingInterval.value); recordingNotes.value = ""; // Clear tags and ASR options for fresh start selectedTagIds.value = []; asrLanguage.value = ""; asrMinSpeakers.value = ""; asrMaxSpeakers.value = ""; }; const drawSingleVisualizer = (analyserNode, canvasElement) => { if (!analyserNode || !canvasElement) return; const bufferLength = analyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyserNode.getByteFrequencyData(dataArray); const canvasCtx = canvasElement.getContext("2d"); const WIDTH = canvasElement.width; const HEIGHT = canvasElement.height; canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); const barWidth = (WIDTH / bufferLength) * 1.5; let barHeight; let x = 0; // Use theme-specific colors that work with all color schemes const buttonColor = getComputedStyle(document.documentElement) .getPropertyValue("--bg-button") .trim(); const buttonHoverColor = getComputedStyle(document.documentElement) .getPropertyValue("--bg-button-hover") .trim(); // Create gradient that works in both light and dark modes const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT); if (isDarkMode.value) { // Dark mode: use button colors with transparency gradient.addColorStop(0, buttonColor); gradient.addColorStop(0.6, buttonHoverColor); gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)"); } else { // Light mode: use more saturated colors for visibility gradient.addColorStop(0, buttonColor); gradient.addColorStop(0.5, buttonHoverColor); gradient.addColorStop(1, "rgba(0, 0, 0, 0.1)"); } for (let i = 0; i < bufferLength; i++) { barHeight = dataArray[i] / 2.5; canvasCtx.fillStyle = gradient; canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight); x += barWidth + 2; } }; const drawVisualizers = () => { if (!isRecording.value) { if (animationFrameId.value) { cancelAnimationFrame(animationFrameId.value); animationFrameId.value = null; } return; } animationFrameId.value = requestAnimationFrame(drawVisualizers); if (recordingMode.value === "both") { drawSingleVisualizer(micAnalyser.value, micVisualizer.value); drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value); } else { drawSingleVisualizer(analyser.value, visualizer.value); } }; // --- Recording Management --- const saveMetadata = async (recordingDataToSave) => { globalError.value = null; if (!recordingDataToSave || !recordingDataToSave.id) return null; console.log("Saving metadata for:", recordingDataToSave.id); try { const payload = { id: recordingDataToSave.id, title: recordingDataToSave.title, participants: recordingDataToSave.participants, notes: recordingDataToSave.notes, summary: recordingDataToSave.summary, meeting_date: recordingDataToSave.meeting_date, }; const response = await fetch("/tool/speakr/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to save metadata"); console.log("Save successful:", data.recording.id); const index = recordings.value.findIndex( (r) => r.id === data.recording.id ); if (index !== -1) { recordings.value[index].title = payload.title; recordings.value[index].participants = payload.participants; recordings.value[index].notes = payload.notes; recordings.value[index].notes_html = data.recording.notes_html; recordings.value[index].summary = payload.summary; recordings.value[index].summary_html = data.recording.summary_html; recordings.value[index].meeting_date = payload.meeting_date; } if (selectedRecording.value?.id === data.recording.id) { selectedRecording.value.title = payload.title; selectedRecording.value.participants = payload.participants; selectedRecording.value.notes = payload.notes; selectedRecording.value.notes_html = data.recording.notes_html; selectedRecording.value.summary = payload.summary; selectedRecording.value.summary_html = data.recording.summary_html; selectedRecording.value.meeting_date = payload.meeting_date; } return data.recording; } catch (error) { console.error("Save Metadata Error:", error); setGlobalError(`Save failed: ${error.message}`); return null; } }; const editRecording = (recording) => { editingRecording.value = JSON.parse(JSON.stringify(recording)); showEditModal.value = true; }; const cancelEdit = () => { showEditModal.value = false; editingRecording.value = null; }; const saveEdit = async () => { const success = await saveMetadata(editingRecording.value); if (success) { cancelEdit(); } }; const confirmDelete = (recording) => { recordingToDelete.value = recording; showDeleteModal.value = true; }; const cancelDelete = () => { showDeleteModal.value = false; recordingToDelete.value = null; }; const deleteRecording = async () => { globalError.value = null; if (!recordingToDelete.value) return; const idToDelete = recordingToDelete.value.id; const titleToDelete = recordingToDelete.value.title; try { const response = await fetch(`/tool/speakr/recording/${idToDelete}`, { method: "DELETE", }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to delete recording"); recordings.value = recordings.value.filter( (r) => r.id !== idToDelete ); totalRecordings.value--; // Update total count const queueIndex = uploadQueue.value.findIndex( (item) => item.recordingId === idToDelete ); if (queueIndex !== -1) { const deletedItem = uploadQueue.value.splice(queueIndex, 1)[0]; console.log(`Removed item ${deletedItem.clientId} from queue.`); if ( currentlyProcessingFile.value?.clientId === deletedItem.clientId ) { console.log( `Deleting currently processing file: ${titleToDelete}. Stopping poll and moving to next.` ); clearInterval(pollInterval.value); pollInterval.value = null; resetCurrentFileProcessingState(); isProcessingActive.value = false; await nextTick(); startProcessingQueue(); } } if (selectedRecording.value?.id === idToDelete) selectedRecording.value = null; cancelDelete(); console.log( `Successfully deleted recording ${idToDelete} (${titleToDelete})` ); } catch (error) { console.error("Delete Error:", error); setGlobalError( `Failed to delete recording "${titleToDelete}": ${error.message}` ); cancelDelete(); } }; // --- Inline Editing --- const toggleEditParticipants = () => { editingParticipants.value = !editingParticipants.value; if (!editingParticipants.value) { saveInlineEdit("participants"); } }; const toggleEditMeetingDate = () => { editingMeetingDate.value = !editingMeetingDate.value; if (!editingMeetingDate.value) { saveInlineEdit("meeting_date"); } }; const toggleEditSummary = () => { editingSummary.value = !editingSummary.value; if (editingSummary.value) { // Store current content in temp variable tempSummaryContent.value = selectedRecording.value.summary || ""; nextTick(() => { initializeSummaryMarkdownEditor(); }); } }; const cancelEditSummary = () => { if (summaryMarkdownEditorInstance.value) { summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } editingSummary.value = false; // Restore original content if (selectedRecording.value) { selectedRecording.value.summary = tempSummaryContent.value; } }; const saveEditSummary = async () => { if (summaryMarkdownEditorInstance.value) { selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } editingSummary.value = false; await saveInlineEdit("summary"); }; const toggleEditNotes = () => { editingNotes.value = !editingNotes.value; if (editingNotes.value) { // Store current content in temp variable tempNotesContent.value = selectedRecording.value.notes || ""; // Initialize markdown editor when entering edit mode nextTick(() => { initializeMarkdownEditor(); }); } }; const cancelEditNotes = () => { if (markdownEditorInstance.value) { markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } editingNotes.value = false; // Restore original content if (selectedRecording.value) { selectedRecording.value.notes = tempNotesContent.value; } }; const saveEditNotes = async () => { if (markdownEditorInstance.value) { // Get the markdown content from the editor selectedRecording.value.notes = markdownEditorInstance.value.value(); markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } editingNotes.value = false; await saveInlineEdit("notes"); }; const clickToEditNotes = () => { // Allow clicking on empty notes area to start editing if ( !editingNotes.value && (!selectedRecording.value?.notes || selectedRecording.value.notes.trim() === "") ) { toggleEditNotes(); } }; const clickToEditSummary = () => { // Allow clicking on empty summary area to start editing if ( !editingSummary.value && (!selectedRecording.value?.summary || selectedRecording.value.summary.trim() === "") ) { toggleEditSummary(); } }; const autoSaveNotes = async () => { if (markdownEditorInstance.value && editingNotes.value) { // Just save the content to the model, don't exit edit mode selectedRecording.value.notes = markdownEditorInstance.value.value(); // Silently save to backend without changing UI state try { const payload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date, }; const response = await fetch("/tool/speakr/save", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); const data = await response.json(); if (response.ok && data.recording) { // Update the HTML rendered versions if they exist if (data.recording.notes_html) { selectedRecording.value.notes_html = data.recording.notes_html; } } else { console.error("Failed to auto-save notes"); } } catch (error) { console.error("Error auto-saving notes:", error); } } }; const autoSaveSummary = async () => { if (summaryMarkdownEditorInstance.value && editingSummary.value) { // Just save the content to the model, don't exit edit mode selectedRecording.value.summary = summaryMarkdownEditorInstance.value.value(); // Silently save to backend without changing UI state try { const payload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date, }; const response = await fetch("/tool/speakr/save", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); const data = await response.json(); if (response.ok && data.recording) { // Update the HTML rendered versions if they exist if (data.recording.summary_html) { selectedRecording.value.summary_html = data.recording.summary_html; } } else { console.error("Failed to auto-save summary"); } } catch (error) { console.error("Error auto-saving summary:", error); } } }; const initializeMarkdownEditor = () => { if (!notesMarkdownEditor.value) return; try { markdownEditorInstance.value = new EasyMDE({ element: notesMarkdownEditor.value, spellChecker: false, autofocus: true, placeholder: "以Markdown格式输入笔记...", initialValue: selectedRecording.value?.notes || "", status: false, toolbar: [ "bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide", ], previewClass: ["editor-preview", "notes-preview"], theme: isDarkMode.value ? "dark" : "light", }); // Add auto-save functionality markdownEditorInstance.value.codemirror.on("change", () => { if (autoSaveTimer.value) { clearTimeout(autoSaveTimer.value); } autoSaveTimer.value = setTimeout(() => { autoSaveNotes(); }, autoSaveDelay); }); } catch (error) { console.error("Failed to initialize markdown editor:", error); // Fallback to regular textarea editing editingNotes.value = true; } }; const getBaseInfo = async () => { try { const response = await fetch(`getUserConfig`, { method: "GET", }); const data = await response.json(); if (data.LEADER_SPEECH_BUTTON) { baseInfo.value = { name: data.LEADER_SPEECH_BUTTON, fontSize: data.LEADER_SPEECH_TEXT_SIZE, }; } configwords = data.END_PHRASE_PATTERNS || ""; } catch (error) { console.error("获取基础配置出错:", error); configwords = ""; } }; const initializeRecordingMarkdownEditor = () => { if (!recordingNotesEditor.value) { console.log("Recording notes editor ref not found"); return; } // Check if EasyMDE is available if (typeof EasyMDE === "undefined") { console.error("EasyMDE is not loaded"); return; } // Clean up existing instance if any if (recordingMarkdownEditorInstance.value) { recordingMarkdownEditorInstance.value.toTextArea(); recordingMarkdownEditorInstance.value = null; } try { console.log("Initializing recording markdown editor"); recordingMarkdownEditorInstance.value = new EasyMDE({ element: recordingNotesEditor.value, spellChecker: false, autofocus: false, placeholder: "Type your notes in Markdown format...", status: false, toolbar: [ "bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "|", "preview", "guide", ], previewClass: ["editor-preview", "notes-preview"], theme: isDarkMode.value ? "dark" : "light", initialValue: recordingNotes.value || "", maxHeight: "300px", // Add height constraint to prevent unlimited growth minHeight: "150px", // Minimum height for usability }); // Sync changes back to the reactive variable recordingMarkdownEditorInstance.value.codemirror.on("change", () => { recordingNotes.value = recordingMarkdownEditorInstance.value.value(); }); console.log("Recording markdown editor initialized successfully"); } catch (error) { console.error( "Failed to initialize recording markdown editor:", error ); // Keep as regular textarea if EasyMDE fails } }; // 识别启动、停止、清空操作 const toggleTranscriptionViewMode = () => { transcriptionViewMode.value = transcriptionViewMode.value === "simple" ? "bubble" : "simple"; localStorage.setItem( "transcriptionViewMode", transcriptionViewMode.value ); }; const toggleChatMaximize = () => { if (isChatMaximized.value) { // If maximized, restore to normal state isChatMaximized.value = false; } else { // If not maximized, maximize and ensure chat is open isChatMaximized.value = true; if (!showChat.value) { showChat.value = true; } } }; const saveInlineEdit = async (field) => { if (!selectedRecording.value) return; const fullPayload = { id: selectedRecording.value.id, title: selectedRecording.value.title, participants: selectedRecording.value.participants, notes: selectedRecording.value.notes, summary: selectedRecording.value.summary, meeting_date: selectedRecording.value.meeting_date, }; try { const updatedRecording = await saveMetadata(fullPayload); if (updatedRecording) { if (field === "notes") { selectedRecording.value.notes_html = updatedRecording.notes_html; } else if (field === "summary") { selectedRecording.value.summary_html = updatedRecording.summary_html; } switch (field) { case "participants": editingParticipants.value = false; break; case "meeting_date": editingMeetingDate.value = false; break; case "summary": editingSummary.value = false; break; case "notes": editingNotes.value = false; break; } showToast( `${ field.charAt(0).toUpperCase() + field.slice(1).replace("_", " ") } updated successfully` ); } } catch (error) { console.error(`Save ${field} Error:`, error); setGlobalError(`Failed to save ${field}: ${error.message}`); } }; // --- Chat Functionality --- // Helper function to check if chat is scrolled to bottom (within bottom 5%) const isChatScrolledToBottom = () => { if (!chatMessagesRef.value) return true; const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value; const scrollableHeight = scrollHeight - clientHeight; if (scrollableHeight <= 0) return true; // No scrolling possible const scrollPercentage = scrollTop / scrollableHeight; return scrollPercentage >= 0.95; // Within bottom 5% }; // Helper function to scroll chat to bottom with smooth behavior const scrollChatToBottom = () => { if (chatMessagesRef.value) { requestAnimationFrame(() => { if (chatMessagesRef.value) { chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight; } }); } }; const sendChatMessage = async () => { if ( !chatInput.value.trim() || isChatLoading.value || !selectedRecording.value || selectedRecording.value.status !== "COMPLETED" ) { return; } const message = chatInput.value.trim(); if (!Array.isArray(chatMessages.value)) { chatMessages.value = []; } chatMessages.value.push({ role: "user", content: message }); chatInput.value = ""; isChatLoading.value = true; await nextTick(); // Always scroll to bottom when user sends a new message scrollChatToBottom(); let assistantMessage = null; try { const messageHistory = chatMessages.value .slice(0, -1) .map((msg) => ({ role: msg.role, content: msg.content })); const response = await fetch("/tool/speakr/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ recording_id: selectedRecording.value.id, message: message, message_history: messageHistory, }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Failed to get chat response"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const processStream = async () => { let isFirstChunk = true; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (line.startsWith("data: ")) { const jsonStr = line.substring(6); if (jsonStr) { try { const data = JSON.parse(jsonStr); if (data.thinking) { // Check scroll position BEFORE updating content const shouldScroll = isChatScrolledToBottom(); if (isFirstChunk) { isChatLoading.value = false; assistantMessage = reactive({ role: "assistant", content: "", html: "", thinking: data.thinking, thinkingExpanded: false, }); chatMessages.value.push(assistantMessage); isFirstChunk = false; } else if (assistantMessage) { // Append to existing thinking content if (assistantMessage.thinking) { assistantMessage.thinking += "\n\n" + data.thinking; } else { assistantMessage.thinking = data.thinking; } } // Scroll if we were at bottom before the update if (shouldScroll) { await nextTick(); scrollChatToBottom(); } } if (data.delta) { // Check scroll position BEFORE updating content const shouldScroll = isChatScrolledToBottom(); if (isFirstChunk) { isChatLoading.value = false; assistantMessage = reactive({ role: "assistant", content: "", html: "", thinking: "", thinkingExpanded: false, }); chatMessages.value.push(assistantMessage); isFirstChunk = false; } assistantMessage.content += data.delta; assistantMessage.html = marked.parse( assistantMessage.content ); // Scroll if we were at bottom before the update if (shouldScroll) { await nextTick(); scrollChatToBottom(); } } if (data.end_of_stream) { return; } if (data.error) { throw new Error(data.error); } } catch (e) { console.error("Error parsing stream data:", e); } } } } } }; await processStream(); } catch (error) { console.error("Chat Error:", error); if (assistantMessage) { assistantMessage.content = `Error: ${error.message}`; assistantMessage.html = `Error: ${error.message}`; } else { chatMessages.value.push({ role: "assistant", content: `Error: ${error.message}`, html: `Error: ${error.message}`, }); } } finally { isChatLoading.value = false; await nextTick(); // Final scroll only if user is at bottom if (isChatScrolledToBottom()) { scrollChatToBottom(); } } }; // --- Column Resizing --- const startColumnResize = (event) => { isResizing.value = true; const startX = event.clientX; const startLeftWidth = leftColumnWidth.value; const handleMouseMove = (e) => { if (!isResizing.value) return; const container = document.getElementById("mainContentColumns"); if (!container) return; const containerRect = container.getBoundingClientRect(); const deltaX = e.clientX - startX; const containerWidth = containerRect.width; const deltaPercent = (deltaX / containerWidth) * 100; let newLeftWidth = startLeftWidth + deltaPercent; newLeftWidth = Math.max(20, Math.min(80, newLeftWidth)); // Constrain between 20% and 80% leftColumnWidth.value = newLeftWidth; rightColumnWidth.value = 100 - newLeftWidth; }; const handleMouseUp = () => { isResizing.value = false; document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); // Save to localStorage localStorage.setItem("transcriptColumnWidth", leftColumnWidth.value); localStorage.setItem("summaryColumnWidth", rightColumnWidth.value); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); event.preventDefault(); }; // --- Chat Input Handling --- const handleChatKeydown = (event) => { if (event.key === "Enter") { if (event.ctrlKey || event.shiftKey) { // Ctrl+Enter or Shift+Enter: add new line (default behavior) return; } else { // Enter: send message event.preventDefault(); sendChatMessage(); } } }; // --- Audio Player --- const seekAudio = (time, context = "main") => { let audioPlayer = null; if (context === "modal") { // The audio player in the modal has the class directly on the audio element audioPlayer = document.querySelector( "audio.speaker-modal-transcript" ); } else { audioPlayer = document.querySelector(".main-content-area audio"); } if (audioPlayer) { const wasPlaying = !audioPlayer.paused; audioPlayer.currentTime = time; if (wasPlaying) { audioPlayer.play(); } } else { console.warn(`Audio player not found for context: ${context}`); // Fallback to old method if new one fails const oldPlayer = document.querySelector("audio"); if (oldPlayer) { const wasPlaying = !oldPlayer.paused; oldPlayer.currentTime = time; if (wasPlaying) { oldPlayer.play(); } } } }; const seekAudioFromEvent = (event) => { const segmentElement = event.target.closest("[data-start-time]"); if (!segmentElement) return; const time = parseFloat(segmentElement.dataset.startTime); if (isNaN(time)) return; // Determine context by checking if we're inside the speaker modal const isInSpeakerModal = event.target.closest(".speaker-modal-transcript") !== null; const context = isInSpeakerModal ? "modal" : "main"; seekAudio(time, context); }; const onPlayerVolumeChange = (event) => { const newVolume = event.target.volume; playerVolume.value = newVolume; localStorage.setItem("playerVolume", newVolume); }; // --- i18n Helper Functions --- // Use the same safeT function that's globally available const t = safeT; const tc = (key, count, params = {}) => { return window.i18n ? window.i18n.tc(key, count, params) : key; }; const changeLanguage = async (langCode) => { if (window.i18n) { await window.i18n.setLocale(langCode); currentLanguage.value = langCode; const lang = availableLanguages.value.find( (l) => l.code === langCode ); currentLanguageName.value = lang ? lang.nativeName : "English"; showLanguageMenu.value = false; isUserMenuOpen.value = false; // Save preference to backend try { await fetch("/tool/speakr/api/user/preferences", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken.value, }, body: JSON.stringify({ language: langCode }), }); } catch (error) { console.error("Failed to save language preference:", error); } } }; // --- Toast Notifications --- const showToast = ( message, icon = "fa-check-circle", duration = 2000 ) => { const toastContainer = document.getElementById("toastContainer"); const toast = document.createElement("div"); toast.className = "toast"; toast.innerHTML = ` ${message}`; toastContainer.appendChild(toast); setTimeout(() => { toast.classList.add("show"); }, 10); setTimeout(() => { toast.classList.remove("show"); setTimeout(() => { toastContainer.removeChild(toast); }, 300); }, duration); }; const animateCopyButton = (button) => { button.classList.add("copy-success"); const originalContent = button.innerHTML; button.innerHTML = ''; setTimeout(() => { button.classList.remove("copy-success"); button.innerHTML = originalContent; }, 1500); }; const copyMessage = (text, event) => { const button = event.currentTarget; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(text) .then(() => { showToast("Copied to clipboard!"); animateCopyButton(button); }) .catch((err) => { console.error("Copy failed:", err); showToast( "Failed to copy: " + err.message, "fa-exclamation-circle" ); fallbackCopyTextToClipboard(text, button); }); } else { fallbackCopyTextToClipboard(text, button); } }; const fallbackCopyTextToClipboard = (text, button = null) => { try { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.left = "-999999px"; textArea.style.top = "-999999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand("copy"); document.body.removeChild(textArea); if (successful) { showToast("Copied to clipboard!"); if (button) animateCopyButton(button); } else { showToast( "Copy failed. Your browser may not support this feature.", "fa-exclamation-circle" ); } } catch (err) { console.error("Fallback copy failed:", err); showToast("Unable to copy: " + err.message, "fa-exclamation-circle"); } }; const copyTranscription = (event) => { if ( !selectedRecording.value || !selectedRecording.value.transcription ) { showToast( "No transcription available to copy.", "fa-exclamation-circle" ); return; } const button = event.currentTarget; let textToCopy = ""; try { const transcriptionData = JSON.parse( selectedRecording.value.transcription ); if (Array.isArray(transcriptionData)) { const wasDiarized = transcriptionData.some( (segment) => segment.speaker ); if (wasDiarized) { textToCopy = transcriptionData .map((segment) => { const speakerName = segment.speaker; return `[${speakerName}]: ${segment.sentence}`; }) .join("\n"); } else { textToCopy = transcriptionData .map((segment) => segment.sentence) .join("\n"); } } else { textToCopy = selectedRecording.value.transcription; } } catch (e) { textToCopy = selectedRecording.value.transcription; } animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(textToCopy) .then(() => { showToast("转录内容已复制到剪贴板!"); }) .catch((err) => { console.error("Copy failed:", err); showToast( "Failed to copy: " + err.message, "fa-exclamation-circle" ); fallbackCopyTextToClipboard(textToCopy); }); } else { fallbackCopyTextToClipboard(textToCopy); } }; const copySummary = (event) => { if (!selectedRecording.value || !selectedRecording.value.summary) { showToast("No summary available to copy.", "fa-exclamation-circle"); return; } const button = event.currentTarget; const textToCopy = selectedRecording.value.summary; animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(textToCopy) .then(() => { showToast("摘要已复制到剪贴板!"); }) .catch((err) => { console.error("Copy failed:", err); showToast( "Failed to copy: " + err.message, "fa-exclamation-circle" ); fallbackCopyTextToClipboard(textToCopy); }); } else { fallbackCopyTextToClipboard(textToCopy); } }; const copyNotes = (event) => { if (!selectedRecording.value || !selectedRecording.value.notes) { showToast("No notes available to copy.", "fa-exclamation-circle"); return; } const button = event.currentTarget; const textToCopy = selectedRecording.value.notes; animateCopyButton(button); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(textToCopy) .then(() => { showToast("Notes copied to clipboard!"); }) .catch((err) => { console.error("Copy failed:", err); showToast( "Failed to copy: " + err.message, "fa-exclamation-circle" ); fallbackCopyTextToClipboard(textToCopy); }); } else { fallbackCopyTextToClipboard(textToCopy); } }; const downloadSummary = async () => { if (!selectedRecording.value || !selectedRecording.value.summary) { showToast( "No summary available to download.", "fa-exclamation-circle" ); return; } try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/download/summary` ); if (!response.ok) { const error = await response.json(); showToast( error.error || "Failed to download summary", "fa-exclamation-circle" ); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; // Get filename from response headers or use default const contentDisposition = response.headers.get( "Content-Disposition" ); const filename = getDownloadFilenameFromHeaders( contentDisposition, "摘要.docx" ); a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast("Summary downloaded successfully!"); } catch (error) { console.error("Download failed:", error); showToast("Failed to download summary", "fa-exclamation-circle"); } }; const downloadTranscript = async () => { if ( !selectedRecording.value || !selectedRecording.value.transcription ) { showToast( "No transcription available to download.", "fa-exclamation-circle" ); return; } try { // First, fetch available templates const templatesResponse = await fetch( "/tool/speakr/api/transcript-templates" ); let templates = []; if (templatesResponse.ok) { templates = await templatesResponse.json(); } // If there are templates, show a selection dialog let templateId = null; if (templates.length > 0) { // Create a simple modal for template selection const modal = document.createElement("div"); modal.className = "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"; modal.innerHTML = `

Select Template

${templates .map( (t) => ` ` ) .join("")}
`; document.body.appendChild(modal); // Wait for user selection await new Promise((resolve) => { modal.querySelectorAll(".template-option").forEach((btn) => { btn.addEventListener("click", () => { templateId = btn.dataset.templateId; modal.remove(); resolve(); }); }); modal .querySelector(".cancel-btn") .addEventListener("click", () => { templateId = "cancelled"; // Mark as cancelled modal.remove(); resolve(); }); modal .querySelector(".download-without-template-btn") .addEventListener("click", () => { templateId = "none"; // Use 'none' to indicate no template modal.remove(); resolve(); }); modal.addEventListener("click", (e) => { if (e.target === modal) { templateId = "cancelled"; // Mark as cancelled when clicking outside modal.remove(); resolve(); } }); }); if ( templateId === null || templateId === undefined || templateId === "cancelled" ) { // User cancelled return; } } // Download the transcript with the selected template const url = templateId && templateId !== "none" ? `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=${templateId}` : `/tool/speakr/recording/${selectedRecording.value.id}/download/transcript?template_id=none`; const response = await fetch(url); if (!response.ok) { throw new Error("Failed to download transcript"); } const blob = await response.blob(); const contentDisposition = response.headers.get( "content-disposition" ); const filename = getDownloadFilenameFromHeaders( contentDisposition, "转录稿.docx" ); // Create download link const downloadUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = downloadUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(downloadUrl); showToast("Transcript downloaded successfully!"); } catch (error) { console.error("Error downloading transcript:", error); showToast("Failed to download transcript", "fa-exclamation-circle"); } }; const downloadNotes = async () => { if (!selectedRecording.value || !selectedRecording.value.notes) { showToast("No notes available to download.", "fa-exclamation-circle"); return; } try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/download/notes` ); if (!response.ok) { const error = await response.json(); showToast( error.error || "Failed to download notes", "fa-exclamation-circle" ); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; // Get filename from response headers or use default const contentDisposition = response.headers.get( "Content-Disposition" ); const filename = getDownloadFilenameFromHeaders( contentDisposition, "笔记.docx" ); a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast("Notes downloaded successfully!", "fa-check-circle"); } catch (error) { console.error("Download failed:", error); showToast("Failed to download notes", "fa-exclamation-circle"); } }; const downloadEventICS = async (event) => { if (!event || !event.id) { showToast("Invalid event data", "fa-exclamation-circle"); return; } try { const response = await fetch( `/tool/speakr/api/event/${event.id}/ics` ); if (!response.ok) { const error = await response.json(); showToast( error.error || "Failed to download event", "fa-exclamation-circle" ); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; a.download = `${event.title || "event"}.ics`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast( `Event "${event.title}" downloaded. Open the file to add to your calendar.`, "fa-calendar-check", 3000 ); } catch (error) { console.error("Download failed:", error); showToast("Failed to download event", "fa-exclamation-circle"); } }; const downloadICS = async () => { if ( !selectedRecording.value || !selectedRecording.value.events || selectedRecording.value.events.length === 0 ) { showToast("No events to export", "fa-exclamation-circle"); return; } try { const response = await fetch( `/tool/speakr/api/recording/${selectedRecording.value.id}/events/ics` ); if (!response.ok) { const error = await response.json(); showToast( error.error || "Failed to export events", "fa-exclamation-circle" ); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; a.download = `events-${ selectedRecording.value.title || selectedRecording.value.id }.ics`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast( `Exported ${selectedRecording.value.events.length} events`, "fa-calendar-check" ); } catch (error) { console.error("Download all events ICS error:", error); showToast("Failed to export events", "fa-exclamation-circle"); } }; const formatEventDateTime = (dateTimeStr) => { if (!dateTimeStr) return ""; try { const date = new Date(dateTimeStr); const options = { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }; return date.toLocaleString(undefined, options); } catch (e) { return dateTimeStr; } }; const downloadChat = async () => { if (!selectedRecording.value || chatMessages.value.length === 0) { showToast("No chat messages to download.", "fa-exclamation-circle"); return; } try { const response = await fetch( `/tool/speakr/recording/${selectedRecording.value.id}/download/chat`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken.value, }, body: JSON.stringify({ messages: chatMessages.value, }), } ); if (!response.ok) { const error = await response.json(); showToast( error.error || "Failed to download chat", "fa-exclamation-circle" ); return; } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.style.display = "none"; a.href = url; // Get filename from response headers or use default const contentDisposition = response.headers.get( "Content-Disposition" ); const filename = getDownloadFilenameFromHeaders( contentDisposition, "对话记录.docx" ); a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast("Notes downloaded successfully!"); } catch (error) { console.error("Download failed:", error); showToast("Failed to download notes", "fa-exclamation-circle"); } }; const clearChat = () => { if (chatMessages.value.length > 0) { chatMessages.value = []; showToast("Chat cleared", "fa-broom"); } }; const openShareModal = async (recording) => { recordingToShare.value = recording; shareOptions.share_summary = true; shareOptions.share_notes = true; generatedShareLink.value = ""; existingShareDetected.value = false; showShareModal.value = true; // Check for existing share try { const response = await fetch( `/tool/speakr/api/recording/${recording.id}/share`, { method: "GET", } ); const data = await response.json(); if (response.ok && data.exists) { generatedShareLink.value = data.share_url; existingShareDetected.value = true; shareOptions.share_summary = data.share.share_summary; shareOptions.share_notes = data.share.share_notes; } } catch (error) { console.error("Error checking for existing share:", error); } }; const closeShareModal = () => { showShareModal.value = false; recordingToShare.value = null; existingShareDetected.value = false; }; const createShare = async (forceNew = false) => { if (!recordingToShare.value) return; try { const response = await fetch( `/tool/speakr/api/recording/${recordingToShare.value.id}/share`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...shareOptions, force_new: forceNew, }), } ); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to create share link"); generatedShareLink.value = data.share_url; existingShareDetected.value = data.existing && !forceNew; if (data.existing && !forceNew) { // Show that we're using an existing share showToast("Using existing share link", "fa-link"); } else { showToast("Share link created successfully!", "fa-check-circle"); } } catch (error) { setGlobalError(`Failed to create share link: ${error.message}`); } }; const copyShareLink = () => { if (!generatedShareLink.value) return; navigator.clipboard.writeText(generatedShareLink.value).then(() => { showToast("Share link copied to clipboard!"); }); }; const openSharesList = async () => { isLoadingShares.value = true; showSharesListModal.value = true; try { const response = await fetch("/tool/speakr/api/shares"); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to load shared items"); userShares.value = data; } catch (error) { setGlobalError(`Failed to load shared items: ${error.message}`); } finally { isLoadingShares.value = false; } }; const closeSharesList = () => { showSharesListModal.value = false; userShares.value = []; }; const updateShare = async (share) => { try { const response = await fetch(`/tool/speakr/api/share/${share.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ share_summary: share.share_summary, share_notes: share.share_notes, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to update share"); showToast("Share permissions updated.", "fa-check-circle"); } catch (error) { setGlobalError(`Failed to update share: ${error.message}`); } }; const confirmDeleteShare = (share) => { shareToDelete.value = share; showShareDeleteModal.value = true; }; const cancelDeleteShare = () => { shareToDelete.value = null; showShareDeleteModal.value = false; }; const deleteShare = async () => { if (!shareToDelete.value) return; const shareId = shareToDelete.value.id; try { const response = await fetch(`/tool/speakr/api/share/${shareId}`, { method: "DELETE", }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to delete share"); userShares.value = userShares.value.filter((s) => s.id !== shareId); showToast("Share link deleted successfully.", "fa-check-circle"); showShareDeleteModal.value = false; shareToDelete.value = null; } catch (error) { setGlobalError(`Failed to delete share: ${error.message}`); } }; // --- Watchers --- watch( uploadQueue, (newQueue, oldQueue) => { if ( newQueue.length === 0 && oldQueue.length > 0 && !isProcessingActive.value ) { console.log("Upload queue processing finished."); setTimeout(() => (progressPopupMinimized.value = true), 500); setTimeout(() => { if ( completedInQueue.value === totalInQueue.value && !isProcessingActive.value ) { progressPopupClosed.value = true; } }, 2000); } }, { deep: true } ); // Watch for changes to speakerMap to handle "This is Me" functionality watch( speakerMap, (newSpeakerMap) => { if (!newSpeakerMap) return; Object.keys(newSpeakerMap).forEach((speakerId) => { const speakerData = newSpeakerMap[speakerId]; if ( speakerData.isMe && currentUserName.value && !speakerData.name ) { // Automatically fill in the user's name when "This is Me" is checked speakerData.name = currentUserName.value; } else if ( !speakerData.isMe && speakerData.name === currentUserName.value ) { // Clear the name if "This is Me" is unchecked and the name matches the current user speakerData.name = ""; } }); }, { deep: true } ); // Watch for tab changes to save content properly watch(selectedTab, (newTab, oldTab) => { // Close maximized chat when switching tabs if (isChatMaximized.value) { isChatMaximized.value = false; } // Save content when switching away from notes tab but keep editor open if ( oldTab === "notes" && editingNotes.value && markdownEditorInstance.value ) { // Save the current content from the editor const currentContent = markdownEditorInstance.value.value(); selectedRecording.value.notes = currentContent; // Call auto-save instead of saveInlineEdit to keep editor open autoSaveNotes(); // Store that we need to recreate the editor when coming back tempNotesContent.value = currentContent; } // Save content when switching away from summary tab but keep editor open if ( oldTab === "summary" && editingSummary.value && summaryMarkdownEditorInstance.value ) { // Save the current content from the editor const currentContent = summaryMarkdownEditorInstance.value.value(); selectedRecording.value.summary = currentContent; // Call auto-save instead of saveInlineEdit to keep editor open autoSaveSummary(); // Store that we need to recreate the editor when coming back tempSummaryContent.value = currentContent; } // Re-initialize editors when switching back to tabs if (newTab === "notes" && editingNotes.value) { // Destroy old instance if exists if (markdownEditorInstance.value) { markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } // Re-initialize the editor in next tick nextTick(() => { initializeMarkdownEditor(); }); } if (newTab === "summary" && editingSummary.value) { // Destroy old instance if exists if (summaryMarkdownEditorInstance.value) { summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } // Re-initialize the editor in next tick nextTick(() => { initializeSummaryMarkdownEditor(); }); } }); // Watch for mobile tab changes similarly watch(mobileTab, (newTab, oldTab) => { // Save content when switching away from notes tab but keep editor open if ( oldTab === "notes" && editingNotes.value && markdownEditorInstance.value ) { const currentContent = markdownEditorInstance.value.value(); selectedRecording.value.notes = currentContent; autoSaveNotes(); // Use auto-save instead tempNotesContent.value = currentContent; } // Save content when switching away from summary tab but keep editor open if ( oldTab === "summary" && editingSummary.value && summaryMarkdownEditorInstance.value ) { const currentContent = summaryMarkdownEditorInstance.value.value(); selectedRecording.value.summary = currentContent; autoSaveSummary(); // Use auto-save instead tempSummaryContent.value = currentContent; } // Re-initialize editors when switching back to tabs on mobile if (newTab === "notes" && editingNotes.value) { if (markdownEditorInstance.value) { markdownEditorInstance.value.toTextArea(); markdownEditorInstance.value = null; } nextTick(() => { initializeMarkdownEditor(); }); } if (newTab === "summary" && editingSummary.value) { if (summaryMarkdownEditorInstance.value) { summaryMarkdownEditorInstance.value.toTextArea(); summaryMarkdownEditorInstance.value = null; } nextTick(() => { initializeSummaryMarkdownEditor(); }); } }); watch(selectedRecording, (newVal, oldVal) => { if (newVal?.id !== oldVal?.id) { // Save any pending edits before switching recordings if ( editingNotes.value && markdownEditorInstance.value && oldVal?.id ) { selectedRecording.value = oldVal; // Temporarily restore old recording saveEditNotes(); // This will save and cleanup selectedRecording.value = newVal; // Switch back to new recording } if ( editingSummary.value && summaryMarkdownEditorInstance.value && oldVal?.id ) { selectedRecording.value = oldVal; // Temporarily restore old recording saveEditSummary(); // This will save and cleanup selectedRecording.value = newVal; // Switch back to new recording } chatMessages.value = []; showChat.value = false; selectedTab.value = "summary"; editingParticipants.value = false; editingMeetingDate.value = false; editingSummary.value = false; editingNotes.value = false; // Fix WebM duration issue by forcing metadata load if (newVal?.id) { nextTick(() => { const audioElements = document.querySelectorAll("audio"); audioElements.forEach((audio) => { if ( audio.src && audio.src.includes(`tool/speakr/audio/${newVal.id}`) ) { // For WebM files, we need to seek to end to get duration const fixDuration = () => { if (!isFinite(audio.duration) || audio.duration === 0) { audio.currentTime = 1e101; // Seek to "infinity" to load duration audio.addEventListener( "timeupdate", function resetTime() { audio.currentTime = 0; audio.removeEventListener("timeupdate", resetTime); }, { once: true } ); } }; // Try to fix duration when metadata loads audio.addEventListener("loadedmetadata", fixDuration, { once: true, }); // Also try immediately in case metadata is already loaded if (audio.readyState >= 1) { fixDuration(); } } }); }); } } }); // 会议记录页面 watch(currentView, (newView) => { if (newView === "recording") { // Initialize recording markdown editor when switching to recording view nextTick(() => { initializeRecordingMarkdownEditor(); getBaseInfo(); is_final.value = false; // 清空上一次的实时转录文本 asrResultOnline.value = ""; // ===== 清理旧的 Recorder 实例 ===== // 根本原因修复:旧 rec 的 onProcess 回调在旧闭包中持续活跃, // 向已停止的旧 wsconnecter 发送数据,导致缓冲区溢出和 WebSocket 未连接刷屏 if (rec) { try { rec.close(); } catch(e) { console.warn('关闭旧rec失败:', e); } rec = null; } if (rec2) { try { rec2.close(); } catch(e) { console.warn('关闭旧rec2失败:', e); } rec2 = null; } if (asrAudioContext) { try { asrAudioContext.close(); } catch(e) {} asrAudioContext = null; } if (asrAudioContext2) { try { asrAudioContext2.close(); } catch(e) {} asrAudioContext2 = null; } //语音实时转换相关 //online的ws示例 var wsconnecter = new WebSocketConnectMethod({ msgHandle: getJsonMessage, stateHandle: getConnState, }); //offline的ws示例 var wsconnecter2 = new WebSocketConnectMethod({ msgHandle: getJsonMessage2, stateHandle: getConnState2, }); webReader = wsconnecter; webReader2 = wsconnecter2; var audioBlob; var sampleBuf = new Int16Array(); var sampleBuf2 = new Int16Array(); const asrChunkSize = 960; const asrBufferWarnSamples = 20000; const asrBufferHardLimitSamples = 16000 * 5; var rec_text = ""; var offline_text = ""; var rec_textOnline = ""; var offlineSentenceHandoffText = ""; var rec_textoffline = ""; var pendingSentenceQueue = []; let pendingSentenceSequence = 0; var lastOnlineTimestamp = null; // 记录上次在线转录的时间戳 function extractTimestampMs(timestampPayload) { if (!timestampPayload) { return null; } try { const timestampData = typeof timestampPayload === "string" ? JSON.parse(timestampPayload) : timestampPayload; if (!Array.isArray(timestampData) || timestampData.length === 0) { return null; } const lastItem = timestampData[timestampData.length - 1]; if (!Array.isArray(lastItem) || lastItem.length === 0) { return null; } const endOrStart = lastItem.length > 1 ? Number(lastItem[1]) : Number(lastItem[0]); return Number.isFinite(endOrStart) ? endOrStart : null; } catch (error) { return null; } } function getLatestRealtimeTimestamp() { const latestSegmentTimestamp = segmentList.reduce( (latest, segment) => Number.isFinite(segment?.timestampMs) ? Math.max(latest, segment.timestampMs) : latest, -1 ); const latestPendingTimestamp = pendingSentenceQueue.reduce( (latest, item) => Number.isFinite(item.timestampMs) ? Math.max(latest, item.timestampMs) : latest, -1 ); const latestOnlineTimestamp = Number.isFinite(lastOnlineTimestamp) ? lastOnlineTimestamp : -1; return Math.max( latestSegmentTimestamp, latestPendingTimestamp, latestOnlineTimestamp ); } function getRealtimeCarryoverTail(text, maxLength = 160) { return (text || "").slice(-maxLength); } function getSharedRealtimeSuffix( originalText, editedText, maxLength = 80 ) { const original = originalText || ""; const edited = editedText || ""; const maxSuffixLength = Math.min( maxLength, original.length, edited.length ); for (let size = maxSuffixLength; size >= 1; size--) { const suffix = edited.slice(-size); if (original.endsWith(suffix)) { return suffix; } } return ""; } function buildRealtimeEditCarryover(baseText) { const tailText = getRealtimeCarryoverTail(baseText); if (tailText.length < 4) { return null; } return { tailText, }; } function trimOfflineTextWithCarryover(rawText) { const incomingText = rawText || ""; if (!realtimeEditCarryover || !incomingText) { return incomingText; } const tailText = realtimeEditCarryover.tailText || ""; const minOverlapLength = 4; const maxOverlapLength = Math.min( 80, tailText.length, incomingText.length ); for ( let size = maxOverlapLength; size >= minOverlapLength; size-- ) { const overlapText = tailText.slice(-size); const matchIndex = incomingText.lastIndexOf(overlapText); if (matchIndex === -1) { continue; } const remainingText = incomingText.slice( matchIndex + overlapText.length ); realtimeEditCarryover.tailText = getRealtimeCarryoverTail( tailText + remainingText ); return remainingText; } realtimeEditCarryover = null; return incomingText; } function clearRealtimePipeline({ preserveTimestampBarrier = false, } = {}) { sampleBuf = new Int16Array(); sampleBuf2 = new Int16Array(); rec_text = ""; offline_text = ""; rec_textOnline = ""; offlineSentenceHandoffText = ""; rec_textoffline = ""; pendingSentenceQueue = []; pendingSentenceSequence = 0; lastOnlineTimestamp = null; asrResultOnline.value = ""; asrResultOffline.value = ""; if (!preserveTimestampBarrier) { realtimeTimestampBarrierMs = -1; } syncRealtimePreview(); } function isStaleRealtimeMessage( messageTimestampMs, messageEpoch = liveTranscriptionEpoch ) { if (messageEpoch !== liveTranscriptionEpoch) { return true; } return ( Number.isFinite(messageTimestampMs) && realtimeTimestampBarrierMs >= 0 && messageTimestampMs <= realtimeTimestampBarrierMs ); } clearRealtimePipelineRef = clearRealtimePipeline; getLatestRealtimeTimestampRef = getLatestRealtimeTimestamp; buildRealtimeEditCarryoverRef = buildRealtimeEditCarryover; renderFullTextRef = renderFullText; function getPendingSentencePreviewText() { return pendingSentenceQueue .filter((item) => item.epoch === liveTranscriptionEpoch) .map((item) => item.correctedText || item.rawText) .join(""); } function syncRealtimePreview() { asrResultOnline.value = getPendingSentencePreviewText() + offlineSentenceHandoffText + rec_textOnline; } function findPendingSentenceBySource( sourceText, pendingTimestamp, messageEpoch = liveTranscriptionEpoch ) { const normalizedSourceText = sourceText || ""; const normalizedTimestamp = pendingTimestamp || ""; return ( pendingSentenceQueue.find( (item) => item.epoch === messageEpoch && item.sourceText === normalizedSourceText && item.timestamp === normalizedTimestamp ) || segmentList.find( (item) => item.epoch === messageEpoch && item.sourceText === normalizedSourceText && item.timestamp === normalizedTimestamp ) || null ); } function ensurePendingSentence( sourceText, pendingTimestamp, messageEpoch = liveTranscriptionEpoch ) { const timestampMs = extractTimestampMs(pendingTimestamp); if (isStaleRealtimeMessage(timestampMs, messageEpoch)) { return null; } const existingItem = findPendingSentenceBySource( sourceText, pendingTimestamp, messageEpoch ); if (existingItem) { return existingItem; } const trimmedText = trimOfflineTextWithCarryover(sourceText || ""); if (!trimmedText) { syncRealtimePreview(); return null; } const pendingItem = { id: pendingSentenceSequence++, sourceText: sourceText || "", rawText: trimmedText, timestamp: pendingTimestamp || "", timestampMs, correctedText: "", processed: false, correctionRequested: false, epoch: messageEpoch, }; pendingSentenceQueue.push(pendingItem); syncRealtimePreview(); return pendingItem; } function flushProcessedPendingSentences() { let didFlush = false; while (pendingSentenceQueue.length > 0) { if (pendingSentenceQueue[0].epoch !== liveTranscriptionEpoch) { pendingSentenceQueue.shift(); continue; } if (!pendingSentenceQueue[0].processed) { break; } const item = pendingSentenceQueue.shift(); segmentList.push({ text: item.correctedText || item.rawText, sourceText: item.sourceText, timestamp: item.timestamp, timestampMs: item.timestampMs, speaker: null, processed: true, epoch: item.epoch, }); didFlush = true; } if (didFlush) { renderFullText(); } else { syncRealtimePreview(); } } function finalizePendingSentence( pendingSentenceId, finalText, messageEpoch = liveTranscriptionEpoch ) { const target = pendingSentenceQueue.find( (item) => item.id === pendingSentenceId && item.epoch === messageEpoch ); if (!target) { return; } target.correctedText = finalText || target.rawText || ""; target.processed = true; target.correctionRequested = false; flushProcessedPendingSentences(); } async function record() { // rec.open(function () { // rec.start(); // }); // await startRecording(); await openMixedStream(); } async function record2() { // rec2.open(function () { // rec2.start(); // }); // await startRecording(); await openMixedStream2(); } function recProcess( buffer, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd ) { if (isRec === true) { var data_48k = buffer[buffer.length - 1]; var array_48k = new Array(data_48k); var data_16k = Recorder.SampleData( array_48k, bufferSampleRate, 16000 ).data; sampleBuf = Int16Array.from([...sampleBuf, ...data_16k]); if (sampleBuf.length > asrBufferHardLimitSamples) { const overflowSamples = sampleBuf.length - asrBufferHardLimitSamples; console.warn( `⚠️ [Online] 丢弃过量本地音频缓冲: ${overflowSamples} samples` ); sampleBuf = sampleBuf.slice(-asrBufferHardLimitSamples); } while (sampleBuf.length >= asrChunkSize) { const sendBuf = sampleBuf.slice(0, asrChunkSize); const sendAccepted = wsconnecter.wsSend(sendBuf); if (!sendAccepted) { break; } sampleBuf = sampleBuf.slice(asrChunkSize); } if (sampleBuf.length > asrBufferWarnSamples) { console.warn( `⚠️ [Online] 音频缓冲区溢出: ${sampleBuf.length} samples (${( sampleBuf.length / 16000 ).toFixed(2)}s)` ); } } } function recProcess2( buffer, powerLevel, bufferDuration, bufferSampleRate, newBufferIdx, asyncEnd ) { if (isRec === true) { var data_48k = buffer[buffer.length - 1]; var array_48k = new Array(data_48k); var data_16k = Recorder.SampleData( array_48k, bufferSampleRate, 16000 ).data; sampleBuf2 = Int16Array.from([...sampleBuf2, ...data_16k]); if (sampleBuf2.length > asrBufferHardLimitSamples) { const overflowSamples = sampleBuf2.length - asrBufferHardLimitSamples; console.warn( `⚠️ [Offline] 丢弃过量本地音频缓冲: ${overflowSamples} samples` ); sampleBuf2 = sampleBuf2.slice(-asrBufferHardLimitSamples); } while (sampleBuf2.length >= asrChunkSize) { const sendBuf2 = sampleBuf2.slice(0, asrChunkSize); const sendAccepted = wsconnecter2.wsSend(sendBuf2); if (!sendAccepted) { break; } sampleBuf2 = sampleBuf2.slice(asrChunkSize); } if (sampleBuf2.length > asrBufferWarnSamples) { console.warn( `⚠️ [Offline] 音频缓冲区溢出: ${sampleBuf2.length} samples (${( sampleBuf2.length / 16000 ).toFixed(2)}s)` ); } } } function addNewlineAfterPeriod(str) { return str.replace(/[。?!]/g, "$&\n"); } function handleWithTimestamp( tmptext, tmptime, keywordStr, timeThreshold ) { if (!tmptime || tmptext.length <= 0) return tmptext; const keywordList = keywordStr .split(",") .map((k) => k.trim()) .filter((k) => k.length > 0); const keywordsAsRegex = keywordList.map((k) => new RegExp(k)); const segments = tmptext.match(/[^。!?]+[。!?]?/g) || []; // console.log(segments, "segments"); const jsontime = JSON.parse(tmptime); let char_index = 0; let text_withtime = ""; // 辅助函数:去除新行开头的标点符号 function removeLeadingPunctuation(text) { // 匹配开头的标点符号(中文和英文标点) return text.replace(/^[,。!?、;:,\.!?;:]+/, ""); } for (let i = 0; i < segments.length; i++) { let seg = segments[i]; if (!seg || seg === "undefined") continue; let nowTime = jsontime[char_index][0] / 1000; if ( lastTimer.value > 0 && nowTime - lastTimer.value > timeThreshold ) { const lastChar = text_withtime.slice(-1); const endsWithPunc = /[。!?]/.test(lastChar); // 换行后,如果新行开头是标点,去除第一个标点 let processedSeg = removeLeadingPunctuation(seg); text_withtime += (endsWithPunc ? "\n" : "。\n") + processedSeg; } else { let hitKeyword = keywordsAsRegex.some((reg) => reg.test(seg)); if (hitKeyword) { // 关键词后换行,如果下一行开头是标点,去除第一个标点 let processedSeg = removeLeadingPunctuation(seg); // text_withtime += processedSeg + "。" + "\n"; } else { text_withtime += seg; } } lastTimer.value = nowTime; char_index++; } return text_withtime; } function getSpeakerBeforeIndex(endIndex) { let previousSpeaker = null; for (let i = 0; i < endIndex; i++) { if (segmentList[i] && segmentList[i].speaker) { previousSpeaker = segmentList[i].speaker; } } return previousSpeaker; } function buildSegmentText(segments, initialSpeaker = null) { let displayText = ""; let lastRenderedSpeaker = initialSpeaker; segments.forEach((seg) => { if (showSpeaker.value) { if ( seg.speaker && seg.speaker !== lastRenderedSpeaker && !seg.speaker.includes("SPK") ) { displayText += (displayText ? "\n" : "") + seg.speaker + ":\n"; lastRenderedSpeaker = seg.speaker; } else if ( seg.speaker && seg.speaker !== lastRenderedSpeaker ) { displayText += (displayText ? "\n" : "") + seg.speaker + ":\n"; lastRenderedSpeaker = seg.speaker; } } let textContent = handleWithTimestamp( seg.text, seg.timestamp, configwords, 6 ); displayText += textContent; }); return displayText; } function renderFullText() { const initialSpeaker = segmentRenderStartIndex > 0 ? getSpeakerBeforeIndex(segmentRenderStartIndex) : null; const displayText = buildSegmentText( segmentList.slice(segmentRenderStartIndex), initialSpeaker ); rec_text = displayText; // 保留历史转录内容(来自暂停/继续草稿) asrResult.value = pausedTranscription.value + rec_text; // 更新主显示区域 // 更新实时显示,展示剩余的待处理句子 + 当前正在识别的内容 syncRealtimePreview(); // 移除旧的清空逻辑 // asrResultOnline.value = ""; // rec_textOnline = ""; } // Legacy implementation kept temporarily for reference; the active // realtime path uses the clean getJsonMessage defined below. function getJsonMessageLegacy(jsonMsg) { const parsedMessage = JSON.parse(jsonMsg.data); var rectxt = "" + parsedMessage["text"]; var asrmodel = parsedMessage["mode"]; var timestamp = parsedMessage["timestamp"]; // 检查是否需要重置转录状态(从暂停/恢复时) if (resetTranscriptionFlag) { console.log("🔄 重置转录状态(暂停/恢复后)"); clearRealtimePipeline(); resetTranscriptionFlag = false; } const messageEpoch = liveTranscriptionEpoch; if (asrmodel == "2pass-online") { // ✅ 实时转录换行逻辑 // 规则1:凡是换行的,末尾必须是句号(。) // 规则2:不修改标点符号 // 规则3:停顿3秒以上才换行 // 解析时间戳(获取最后一个字符的时间) const currentTimestamp = extractTimestampMs(timestamp); if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) { syncRealtimePreview(); return; } // 检查是否需要换行 let shouldBreak = false; if (lastOnlineTimestamp !== null && currentTimestamp !== null) { // 计算时间差(单位:秒) const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000; // 停顿超过3秒,且当前文本以句号结尾 if (timeDiff >= 3 && /。\s*$/.test(rectxt)) { shouldBreak = true; } } // 拼接文本(不修改标点) if (shouldBreak) { rec_textOnline = rec_textOnline + rectxt + "\n"; } else { rec_textOnline = rec_textOnline + rectxt; } // 更新时间戳 lastOnlineTimestamp = currentTimestamp; } else if (asrmodel == "2pass-offline") { // Bug fix: 使用offline返回的完整文本替代可能不完整的online累积文本 // rectxt包含了完整的转录结果,比rec_textOnline更准确 ensurePendingSentence(rectxt, timestamp, messageEpoch); rec_textOnline = ""; lastOnlineTimestamp = null; // 重置时间戳 } else{ } // 更新显示:队列中的内容 + 当前实时内容 syncRealtimePreview(); } function getJsonMessage(jsonMsg) { const parsedMessage = JSON.parse(jsonMsg.data); var rectxt = "" + parsedMessage["text"]; var asrmodel = parsedMessage["mode"]; var timestamp = parsedMessage["timestamp"]; if (resetTranscriptionFlag) { console.log("重置实时转录状态(暂停/恢复后)"); clearRealtimePipeline(); resetTranscriptionFlag = false; } const messageEpoch = liveTranscriptionEpoch; if (asrmodel == "2pass-online") { const currentTimestamp = extractTimestampMs(timestamp); if (isStaleRealtimeMessage(currentTimestamp, messageEpoch)) { syncRealtimePreview(); return; } let shouldBreak = false; if (lastOnlineTimestamp !== null && currentTimestamp !== null) { const timeDiff = (currentTimestamp - lastOnlineTimestamp) / 1000; if (timeDiff >= 3 && /。\s*$/.test(rectxt)) { shouldBreak = true; } } if (shouldBreak) { rec_textOnline = rec_textOnline + rectxt + "\n"; } else { rec_textOnline = rec_textOnline + rectxt; } lastOnlineTimestamp = currentTimestamp; } else if (asrmodel == "2pass-offline") { // 离线句子的正式入队只走 getJsonMessage2,避免双通路重复落句。 rec_textOnline = ""; offlineSentenceHandoffText = rectxt || rec_textOnline || ""; rec_textOnline = ""; lastOnlineTimestamp = null; } syncRealtimePreview(); } async function getJsonMessage2(jsonMsg) { const parsedMessage = JSON.parse(jsonMsg.data); var asrmodeltype = parsedMessage["mode"]; var timestamp = parsedMessage["timestamp"]; const messageEpoch = liveTranscriptionEpoch; const messageTimestampMs = extractTimestampMs(timestamp); // ============================ // 场景 A: 2pass-offline (文本先到) // ============================ if (asrmodeltype == "2pass-offline") { if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) { return; } var rectxt = "" + parsedMessage["text"]; rec_textOnline = ""; lastOnlineTimestamp = null; // 3. 准备进行 LLM 矫正 // 使用当前数组里已有的文本 (就是 2pass-offline 的文本) const pendingSentence = ensurePendingSentence( rectxt, timestamp, messageEpoch ); offlineSentenceHandoffText = ""; syncRealtimePreview(); if ( !pendingSentence || pendingSentence.processed || pendingSentence.correctionRequested ) { return; } pendingSentence.correctionRequested = true; pendingCorrectionCount++; try { const response = await fetch(`/tool/speakr/api/asr/correct`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: pendingSentence.rawText }), // 发送当前新增文本去矫正 }); if (!response.ok) { throw new Error("ASR correction failed"); } const data = await response.json(); let correctedText = data.corrected_text || pendingSentence.rawText; // 4. LLM 回来后,直接更新文本内容(自动应用) finalizePendingSentence( pendingSentence.id, correctedText, messageEpoch ); console.log("LLM矫正完成"); } catch (error) { finalizePendingSentence( pendingSentence.id, pendingSentence.rawText, messageEpoch ); console.error("LLM矫正出错:", error); } finally { pendingCorrectionCount = Math.max( 0, pendingCorrectionCount - 1 ); } } // ============================ // 场景 B: offline-speaker (说话人后到) // ============================ else if (asrmodeltype == "offline-speaker") { if (isStaleRealtimeMessage(messageTimestampMs, messageEpoch)) { return; } // 【修改点】:这里不再获取 full_text 用于替换 // var rectxt = "" + JSON.parse(jsonMsg.data)["full_text"]; // 这行删掉或注释 let nowSpeaker = (parsedMessage["segments"] && parsedMessage["segments"][0] && parsedMessage["segments"][0].speaker) || "未命名演讲者"; // 找到最后一个还没认领说话人的句子 let targetIndex = -1; for (let i = segmentList.length - 1; i >= 0; i--) { const segment = segmentList[i]; if (!segment || segment.epoch !== messageEpoch) { continue; } if ( segment.speaker === null && Number.isFinite(messageTimestampMs) && Number.isFinite(segment.timestampMs) && segment.timestampMs === messageTimestampMs ) { targetIndex = i; break; } if (targetIndex === -1 && segment.speaker === null) { targetIndex = i; } } if (targetIndex !== -1) { // 1. 只更新说话人 segmentList[targetIndex].speaker = nowSpeaker; // 【关键】:这里绝对不更新 text,保持 2pass-offline 时的原样 // segmentList[targetIndex].text = ...; // 不要这行 // 2. 立即渲染 // 效果:文本没变(不会跳变),只是头上多了个名字 renderFullText(); } } } // 连接状态响应 function getConnState(connState) { if (connState === 0) { record(); } else if (connState === 1) { } else if (connState === 2) { stop(); } } // 连接状态响应 function getConnState2(connState) { if (connState === 0) { record2(); } else if (connState === 1) { } else if (connState === 2) { stop(); } } async function getMicrophoneInfo() { if ( !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices ) { console.warn("当前浏览器不支持获取设备信息"); return null; } const devices = await navigator.mediaDevices.enumerateDevices(); const mics = devices.filter((d) => d.kind === "audioinput"); // 当前默认麦克风(大多数浏览器) const defaultMic = mics.find((d) => d.deviceId === "default") || mics[0]; return { name: defaultMic?.label || "未知麦克风", deviceId: defaultMic?.deviceId || "", all: mics.map((d) => ({ name: d.label, deviceId: d.deviceId, })), }; } async function openMixedStream() { try { const micInfo = await getMicrophoneInfo(); if (micInfo) { console.log("🎤 当前麦克风:", micInfo.name, micInfo); } // 获取麦克风音频流 const micStreams = micStream; // 尝试获取系统音频流 let systemStreams; try { systemStreams = systemStream; } catch (err) { console.warn("未能获取系统音频,仅使用麦克风录音:", err); } // 创建音频上下文和混音目标 if (asrAudioContext) { try { asrAudioContext.close(); } catch(e) {} } asrAudioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioContext = asrAudioContext; const destination = audioContext.createMediaStreamDestination(); // 把麦克风接入混音输出 const micSource = audioContext.createMediaStreamSource(micStreams); micSource.connect(destination); // 如果有系统音频,混入 if (systemStream && systemStream.getAudioTracks().length > 0) { const sysSource = audioContext.createMediaStreamSource(systemStream); sysSource.connect(destination); } else { console.log("⚠️ 未检测到系统音频,使用麦克风录音"); } // 打开 recorder 并传入混合后的流 rec.open( () => { rec.start(destination.stream); }, (msg) => { console.error("❌ 录音权限被拒绝:", msg); } ); } catch (err) { console.error("初始化录音失败:", err); } } async function openMixedStream2() { try { // 获取麦克风音频流 const micStreams = micStream; // 尝试获取系统音频流(Chrome 需要用户手动勾选“共享系统音频”) let systemStreams; try { systemStreams = systemStream; } catch (err) { console.warn("未能获取系统音频,仅使用麦克风录音:", err); } // 创建音频上下文和混音目标 if (asrAudioContext2) { try { asrAudioContext2.close(); } catch(e) {} } asrAudioContext2 = new (window.AudioContext || window.webkitAudioContext)(); const audioContext = asrAudioContext2; const destination = audioContext.createMediaStreamDestination(); // 把麦克风接入混音输出 const micSource = audioContext.createMediaStreamSource(micStreams); micSource.connect(destination); // 如果有系统音频,混入 if (systemStream && systemStream.getAudioTracks().length > 0) { const sysSource = audioContext.createMediaStreamSource(systemStream); sysSource.connect(destination); } else { console.log("⚠️ 未检测到系统音频,使用麦克风录音"); } // 打开 recorder 并传入混合后的流 rec2.open( () => { rec2.start(destination.stream); }, (msg) => { console.error("❌ 录音权限被拒绝:", msg); } ); } catch (err) { console.error("初始化录音失败:", err); } } const start = () => { // 清除显示 // clear(); //控件状态更新 //启动连接 var ret = wsconnecter.wsStart(); if (ret == 1) { isRec = true; return 1; } else { return 0; } }; const start2 = () => { // 清除显示 // clear(); //控件状态更新 //启动连接(offline) var ret = wsconnecter2.wsStart("offline"); if (ret == 1) { isRec = true; return 1; } else { return 0; } }; if (!isPaused.value) { start(); start2(); } // 录音; 定义录音对象,wav格式 rec = Recorder({ type: "pcm", bitRate: 16, sampleRate: 16000, onProcess: recProcess, }); rec2 = Recorder({ type: "pcm", bitRate: 16, sampleRate: 16000, onProcess: recProcess2, }); // Fix audio duration for recorded files if (audioBlobURL.value) { const recordingAudio = document.querySelector( 'audio[src*="blob:"]' ); if (recordingAudio) { const fixDuration = () => { if ( !isFinite(recordingAudio.duration) || recordingAudio.duration === 0 ) { const originalTime = recordingAudio.currentTime; recordingAudio.currentTime = 1e101; recordingAudio.addEventListener( "timeupdate", function resetTime() { recordingAudio.currentTime = originalTime; recordingAudio.removeEventListener( "timeupdate", resetTime ); }, { once: true } ); } }; recordingAudio.addEventListener("loadedmetadata", fixDuration, { once: true, }); recordingAudio.addEventListener("canplay", fixDuration, { once: true, }); if (recordingAudio.readyState >= 1) { setTimeout(fixDuration, 100); } } } }); } else { liveTranscriptionEpoch += 1; realtimeTimestampBarrierMs = -1; clearRealtimePipelineRef = null; getLatestRealtimeTimestampRef = null; buildRealtimeEditCarryoverRef = null; renderFullTextRef = null; isRec = false; analysisResult.value = ""; asrResult.value = ""; segmentList = []; segmentRenderStartIndex = 0; isEditingTranscription.value = false; is_final.value = false; if (recordingMarkdownEditorInstance.value) { recordingMarkdownEditorInstance.value.toTextArea(); recordingMarkdownEditorInstance.value = null; } if (webReader) { webReader.wsStop(); } if (webReader2) { webReader2.wsStop(); } // 关闭 Recorder 实例,停止 onProcess 回调 if (rec) { try { rec.close(); } catch(e) {} rec = null; } if (rec2) { try { rec2.close(); } catch(e) {} rec2 = null; } if (asrAudioContext) { try { asrAudioContext.close(); } catch(e) {} asrAudioContext = null; } if (asrAudioContext2) { try { asrAudioContext2.close(); } catch(e) {} asrAudioContext2 = null; } // Clean up recording markdown editor when leaving recording view } }); // 监听内容变化 watch(asrResult, async () => { await nextTick(); // 等 DOM 更新完再操作 if (asrTextarea.value) { // console.log(asrTextarea.value.scrollHeight,'asrTextarea.value.scrollHeight'); asrTextarea.value.scrollTop = asrTextarea.value.scrollHeight; } if (asrResultTextarea.value) { asrResultTextarea.value.scrollTop = asrResultTextarea.value.scrollHeight; } }); // 监听内容变化 watch(asrResultOnline, async () => { await nextTick(); // 等 DOM 更新完再操作 if (asrResultTextareaOnline.value) { asrResultTextareaOnline.value.scrollTop = asrResultTextareaOnline.value.scrollHeight; } }); watch(asrResultOffline, async () => { await nextTick(); // 等 DOM 更新完再操作 if (asrResultTextareaOnline.value) { asrResultTextareaOnline.value.scrollTop = asrResultTextareaOnline.value.scrollHeight; } }); let realtimeAutoScrollFrame = null; const scrollRealtimeTextareaToBottom = (textarea) => { if (!textarea) return; textarea.scrollTop = textarea.scrollHeight; }; const scrollVisibleRealtimeTextareas = () => { [ asrTextarea.value, asrResultTextarea.value, asrResultTextareaOnline.value, ].forEach(scrollRealtimeTextareaToBottom); }; const scheduleRealtimeAutoScroll = async (force = false) => { if (isEditingTranscription.value && !force) { return; } await nextTick(); scrollVisibleRealtimeTextareas(); if ( typeof window === "undefined" || typeof window.requestAnimationFrame !== "function" ) { return; } if (realtimeAutoScrollFrame !== null) { window.cancelAnimationFrame(realtimeAutoScrollFrame); realtimeAutoScrollFrame = null; } realtimeAutoScrollFrame = window.requestAnimationFrame(() => { scrollVisibleRealtimeTextareas(); realtimeAutoScrollFrame = window.requestAnimationFrame(() => { scrollVisibleRealtimeTextareas(); realtimeAutoScrollFrame = null; }); }); }; watch(fullRealtimeTranscription, () => { scheduleRealtimeAutoScroll(); }); watch(isEditingTranscription, () => { scheduleRealtimeAutoScroll(true); }); watch(isyjDialogVisible, (visible) => { if (visible) { scheduleRealtimeAutoScroll(true); } }); watch(analysisResult, async () => { await nextTick(); // 等 DOM 更新完再操作 if (sumupResultTextarea.value) { sumupResultTextarea.value.scrollTop = sumupResultTextarea.value.scrollHeight; } }); watch(audioBlobURL, (newURL) => { if (newURL) { nextTick(() => { const recordingAudio = document.querySelector( 'audio[src*="blob:"]' ); if (recordingAudio) { const fixDuration = () => { if ( !isFinite(recordingAudio.duration) || recordingAudio.duration === 0 ) { const originalTime = recordingAudio.currentTime; // recordingAudio.currentTime = 1e101; recordingAudio.currentTime = originalTime + 0.01 recordingAudio.addEventListener( "timeupdate", function resetTime() { recordingAudio.currentTime = originalTime; recordingAudio.removeEventListener( "timeupdate", resetTime ); }, { once: true } ); } }; // Multiple events to ensure we catch the duration recordingAudio.addEventListener("loadedmetadata", fixDuration, { once: true, }); recordingAudio.addEventListener("loadeddata", fixDuration, { once: true, }); recordingAudio.addEventListener("canplay", fixDuration, { once: true, }); // Also try after a delay if (recordingAudio.readyState >= 1) { setTimeout(fixDuration, 100); } } }); } }); watch(playerVolume, (newVolume) => { const audioElements = document.querySelectorAll("audio"); audioElements.forEach((audio) => { if (audio.volume !== newVolume) { audio.volume = newVolume; } }); }); // Watch for search query changes watch(searchQuery, (newQuery) => { debouncedSearch(newQuery); }); // Auto-apply filters when they change (except text query which is debounced) watch( filterTags, () => { applyAdvancedFilters(); }, { deep: true } ); watch(filterDatePreset, () => { applyAdvancedFilters(); }); watch( filterDateRange, () => { applyAdvancedFilters(); }, { deep: true } ); // Debounce text query changes watch(filterTextQuery, (newValue) => { clearTimeout(searchDebounceTimer.value); searchDebounceTimer.value = setTimeout(() => { applyAdvancedFilters(); }, 300); }); // --- Configuration Loading --- const loadConfiguration = async () => { try { const response = await fetch("/tool/speakr/api/config"); if (response.ok) { const config = await response.json(); maxFileSizeMB.value = config.max_file_size_mb || 250; chunkingEnabled.value = config.chunking_enabled !== undefined ? config.chunking_enabled : true; chunkingMode.value = config.chunking_mode || "size"; chunkingLimit.value = config.chunking_limit || 20; chunkingLimitDisplay.value = config.chunking_limit_display || "20MB"; recordingDisclaimer.value = config.recording_disclaimer || ""; console.log( `Loaded configuration: max size ${ maxFileSizeMB.value }MB, chunking ${ chunkingEnabled.value ? "enabled" : "disabled" } (${chunkingLimitDisplay.value})` ); } else { console.warn("Failed to load configuration, using default values"); } } catch (error) { console.error("Error loading configuration:", error); // Keep default values on error } }; const initializeSummaryMarkdownEditor = () => { if (!summaryMarkdownEditor.value) return; try { summaryMarkdownEditorInstance.value = new EasyMDE({ element: summaryMarkdownEditor.value, spellChecker: false, autofocus: true, placeholder: "Enter summary in Markdown format...", initialValue: selectedRecording.value?.summary || "", status: false, toolbar: [ "bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide", ], previewClass: ["editor-preview", "notes-preview"], theme: isDarkMode.value ? "dark" : "light", }); // Add auto-save functionality summaryMarkdownEditorInstance.value.codemirror.on("change", () => { if (autoSaveTimer.value) { clearTimeout(autoSaveTimer.value); } autoSaveTimer.value = setTimeout(() => { autoSaveSummary(); }, autoSaveDelay); }); } catch (error) { console.error("Failed to initialize summary markdown editor:", error); editingSummary.value = true; } }; const pollInboxRecordings = async () => { try { const response = await fetch("/tool/speakr/api/inbox_recordings"); if (!response.ok) { // Silently fail, as this is a background task return; } const inboxRecordings = await response.json(); if (inboxRecordings && inboxRecordings.length > 0) { inboxRecordings.forEach((recording) => { // Add to main recordings list if it's not there already const existingRecording = recordings.value.find( (r) => r.id === recording.id ); if (!existingRecording) { recordings.value.unshift(recording); totalRecordings.value++; // Update total count } // Check if this recording is already in the upload queue or currently being processed const existingItem = uploadQueue.value.find( (item) => item.recordingId === recording.id ); const isCurrentlyProcessing = currentlyProcessingFile.value && currentlyProcessingFile.value.recordingId === recording.id; // Don't add if it's already in queue or being processed if (existingItem || isCurrentlyProcessing) { // Update status if the existing item status has changed if (existingItem && existingItem.status !== recording.status) { console.log( `Updating status for existing recording ${recording.original_filename}: ${existingItem.status} -> ${recording.status}` ); } return; } // Only add recordings that are still processing (not completed) if (recording.status === "COMPLETED") { console.log( `Skipping completed inbox recording: ${recording.original_filename}` ); return; } console.log( `Found new inbox recording: ${recording.original_filename} (status: ${recording.status})` ); // Don't add recordings that are already being handled by main processing if ( recording.status === "SUMMARIZING" && isProcessingActive.value ) { console.log( `Skipping ${recording.original_filename} - already in summarization phase of main processing` ); return; } // Add it to the queue to be displayed in the progress modal const inboxItem = { file: { name: recording.original_filename || `Recording ${recording.id}`, size: recording.file_size || 0, }, status: "pending", // It's already being processed on backend recordingId: recording.id, clientId: `inbox-${recording.id}-${Date.now()}`, error: null, isReprocessing: true, // Use reprocessing logic to poll for status reprocessType: "transcription", }; uploadQueue.value.unshift(inboxItem); // If nothing is currently being processed, make this the active item for the main progress bar if (!isProcessingActive.value && !currentlyProcessingFile.value) { currentlyProcessingFile.value = inboxItem; } // Show progress modal if it's hidden progressPopupMinimized.value = false; progressPopupClosed.value = false; // Start polling its status startReprocessingPoll(recording.id); }); } } catch (error) { console.error("Error polling for inbox recordings:", error); } }; // --- Audio Format Detection --- const detectSupportedAudioFormats = () => { const formats = [ "audio/webm;codecs=opus", "audio/webm;codecs=vp9", "audio/webm", "audio/mp4;codecs=mp4a.40.2", "audio/mp4", "audio/ogg;codecs=opus", "audio/wav", ]; const supportedFormats = formats.filter((format) => MediaRecorder.isTypeSupported(format) ); console.log("Supported audio recording formats:", supportedFormats); if (supportedFormats.length === 0) { console.warn( "No optimized audio formats supported, will use browser default" ); } return supportedFormats; }; // --- System Audio Detection --- const detectSystemAudioCapabilities = async () => { systemAudioSupported.value = false; canRecordSystemAudio.value = false; systemAudioError.value = ""; // Check if getDisplayMedia is available if ( !navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia ) { systemAudioError.value = "getDisplayMedia API not supported"; return; } try { // Test if we can request system audio (this will prompt user) // We'll do this only when user actually tries to record systemAudioSupported.value = true; canRecordSystemAudio.value = true; systemAudioError.value = ""; } catch (error) { systemAudioError.value = error.message; console.warn("System audio detection failed:", error); } }; const detectBrowser = () => { const userAgent = navigator.userAgent; if (userAgent.includes("Firefox")) { browser.value = "firefox"; } else if (userAgent.includes("Chrome")) { browser.value = "chrome"; } else if (userAgent.includes("Safari")) { browser.value = "safari"; } else if ( userAgent.includes("MSIE") || userAgent.includes("Trident/") ) { browser.value = "ie"; } else { browser.value = "unknown"; } }; // --- Lifecycle --- onMounted(async () => { const loader = document.getElementById("loader"); const app = document.getElementById("app"); if (loader) { loader.style.opacity = "0"; setTimeout(() => { loader.style.display = "none"; }, 500); } if (app) { app.style.opacity = "1"; } const appDiv = document.getElementById("app"); if (appDiv) { const asrFlag = appDiv.dataset.useAsrEndpoint; useAsrEndpoint.value = asrFlag === "True" || asrFlag === "true"; currentUserName.value = appDiv.dataset.currentUserName || ""; } // Load configuration first await loadConfiguration(); // Detect system audio capabilities await detectSystemAudioCapabilities(); detectBrowser(); // Check if language was changed in account settings if (localStorage.getItem("ui_language_changed") === "true") { localStorage.removeItem("ui_language_changed"); // Force reload to apply new language window.location.reload(); return; } // i18n is already initialized before Vue app creation if (window.i18n) { currentLanguage.value = window.i18n.getLocale(); availableLanguages.value = window.i18n.getAvailableLocales(); const lang = availableLanguages.value.find( (l) => l.code === currentLanguage.value ); currentLanguageName.value = lang ? lang.nativeName : "English"; // Listen for locale changes window.addEventListener("localeChanged", (event) => { currentLanguage.value = event.detail.locale; const lang = availableLanguages.value.find( (l) => l.code === currentLanguage.value ); currentLanguageName.value = lang ? lang.nativeName : "English"; }); } loadRecordings(); loadTags(); loadDrafts(); // 加载草稿列表 initializeDarkMode(); initializeColorScheme(); const savedVolume = localStorage.getItem("playerVolume"); if (savedVolume !== null) { playerVolume.value = parseFloat(savedVolume); } const savedTranscriptionViewMode = localStorage.getItem( "transcriptionViewMode" ); if (savedTranscriptionViewMode) { transcriptionViewMode.value = savedTranscriptionViewMode; } // Load saved column widths const savedLeftWidth = localStorage.getItem("transcriptColumnWidth"); const savedRightWidth = localStorage.getItem("summaryColumnWidth"); if (savedLeftWidth && savedRightWidth) { leftColumnWidth.value = parseFloat(savedLeftWidth); rightColumnWidth.value = parseFloat(savedRightWidth); } const updateMobileStatus = () => { windowWidth.value = window.innerWidth; }; window.addEventListener("resize", updateMobileStatus); updateMobileStatus(); // Start polling for inbox recordings setInterval(pollInboxRecordings, 10000); const handleEsc = (e) => { if (e.key === "Escape") { if (showColorSchemeModal.value) { closeColorSchemeModal(); } if (showEditModal.value) { cancelEdit(); } if (showDeleteModal.value) { cancelDelete(); } if (showSortOptions.value) { showSortOptions.value = false; } } }; document.addEventListener("keydown", handleEsc); // Click away handler for dropdowns const handleClickAway = (e) => { // Close user menu if clicking outside if (isUserMenuOpen.value) { // Check if we clicked within the user menu area (button or dropdown) const userMenuButton = e.target.closest( 'button[class*="flex items-center gap"]' ); const userMenuDropdown = e.target.closest( 'div[class*="absolute right-0"]' ); // Check if the click was on the user menu button specifically const isUserMenuButtonClick = userMenuButton && userMenuButton.querySelector("i.fa-user-circle"); // If we didn't click on the user menu button or dropdown, close it if (!isUserMenuButtonClick && !userMenuDropdown) { isUserMenuOpen.value = false; } } // Close speaker suggestions if clicking outside in the modal if (showSpeakerModal.value) { // If not clicking on an input field or suggestion dropdown const clickedOnInput = e.target.closest('input[type="text"]'); const clickedOnSuggestion = e.target.closest( '[class*="absolute z-10"]' ); if (!clickedOnInput && !clickedOnSuggestion) { // Close all suggestion dropdowns Object.keys(speakerSuggestions.value).forEach((speakerId) => { speakerSuggestions.value[speakerId] = []; }); } } }; document.addEventListener("click", handleClickAway); }); // --- 转录文本编辑功能 --- // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。 let editModeOriginalText = ""; function enterEditMode() { editModeOriginalText = fullRealtimeTranscription.value || ""; editableText.value = editModeOriginalText; editModeBufferStartIndex = segmentList.length; const barrierTimestampMs = getLatestRealtimeTimestampRef ? getLatestRealtimeTimestampRef() : null; startNewRealtimeEpoch({ barrierTimestampMs, clearEditCarryover: false, }); realtimeEditCarryover = buildRealtimeEditCarryoverRef ? buildRealtimeEditCarryoverRef(editModeOriginalText) : null; isEditingTranscription.value = true; } function exitEditMode() { const nextText = editableText.value ?? ""; isEditingTranscription.value = false; pausedTranscription.value = nextText; asrResult.value = nextText; asrResultOnline.value = ""; asrResultOffline.value = ""; segmentRenderStartIndex = editModeBufferStartIndex; editModeOriginalText = nextText; if (renderFullTextRef) { renderFullTextRef(); } } return { // Core State currentView, dragover, recordings, selectedRecording, selectedTab, searchQuery, isLoadingRecordings, globalError, maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, chunkingLimitDisplay, sortBy, showAdvancedFilters, filterTags, filterDateRange, filterDatePreset, filterTextQuery, // Pagination State currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage, isLoadingMore, // UI State browser, isSidebarCollapsed, searchTipsExpanded, isUserMenuOpen, isDarkMode, currentColorScheme, showColorSchemeModal, windowWidth, isMobileScreen, mobileTab, isMetadataExpanded, // i18n State currentLanguage, currentLanguageName, availableLanguages, showLanguageMenu, // Upload State uploadQueue, currentlyProcessingFile, processingProgress, processingMessage, isProcessingActive, progressPopupMinimized, progressPopupClosed, totalInQueue, completedInQueue, finishedFilesInQueue, clearCompletedUploads, // Audio Recording editingDraftId, editingDraftName, isRecording, canRecordAudio, canRecordSystemAudio, systemAudioSupported, systemAudioError, audioBlobURL, recordingTime, recordingNotes, visualizer, micVisualizer, systemVisualizer, recordingMode, recordingDisclaimer, showRecordingDisclaimerModal, acceptRecordingDisclaimer, cancelRecordingDisclaimer, showAdvancedOptions, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, asrLanguage, asrMinSpeakers, asrMaxSpeakers, availableTags, selectedTagIds, selectedTags, uploadTagSearchFilter, filteredAvailableTagsForUpload, onTagSelected, addTagToSelection, removeTagFromSelection, showSystemAudioHelp, // Draft/Pause Recording isPaused, isStoppingRecording, currentDraftId, showRecordingControls, pausedDuration, pausedTranscription, draftRecordings, draftsExpanded, pauseRecording, resumeRecording, loadDrafts, continueDraftRecording, deleteDraft, // Modal State showEditModal, showDeleteModal, showResetModal, editingRecording, recordingToDelete, showEditTagsModal, selectedNewTagId, tagSearchFilter, filteredAvailableTagsForModal, editRecordingTags, closeEditTagsModal, addTagToRecording, removeTagFromRecording, getRecordingTags, getAvailableTagsForRecording, filterByTag, clearTagFilter, applyAdvancedFilters, clearAllFilters, showTextEditorModal, showAsrEditorModal, editingTranscriptionContent, editingSegments, availableSpeakers, // Inline Editing editingParticipants, editingMeetingDate, editingSummary, editingNotes, // Markdown Editor notesMarkdownEditor, markdownEditorInstance, summaryMarkdownEditor, summaryMarkdownEditorInstance, recordingNotesEditor, // Transcription transcriptionViewMode, legendExpanded, highlightedSpeaker, processedTranscription, // Chat showChat, isChatMaximized, toggleChatMaximize, chatMessages, chatInput, isChatLoading, chatMessagesRef, // Audio Player playerVolume, // Column Resizing leftColumnWidth, rightColumnWidth, isResizing, // App Configuration useAsrEndpoint, currentUserName, // Computed filteredRecordings, groupedRecordings, activeRecordingMetadata, datePresetOptions, languageOptions, // Color Schemes colorSchemes, asrResult, asrResultOnline, asrResultOffline, fullRealtimeTranscription, asrTextarea, asrResultTextarea, asrResultTextareaOnline, analysisResult, sumupResultTextarea, isDialogVisible, isyjDialogVisible, baseInfo, isModalOpen, urlinfo, urlmsg, zjloding, // Methods openmodel, openzjmodel, closeModal, setGlobalError, formatFileSize, formatDisplayDate, formatStatus, getStatusClass, formatTime, t, tc, changeLanguage, toggleDarkMode, applyColorScheme, initializeColorScheme, openColorSchemeModal, closeColorSchemeModal, selectColorScheme, resetColorScheme, toggleSidebar, switchToUploadView, selectRecording, handleDragOver, handleDragLeave, handleDrop, handleFileSelect, addFilesToQueue, startRecording, stopRecording, uploadRecordedAudio, discardRecording, downloadRecordedAudio, loadRecordings, loadMoreRecordings, performSearch, debouncedSearch, saveMetadata, editRecording, cancelEdit, saveEdit, confirmDelete, cancelDelete, deleteRecording, toggleEditParticipants, toggleEditMeetingDate, toggleEditSummary, cancelEditSummary, saveEditSummary, toggleEditNotes, cancelEditNotes, saveEditNotes, initializeMarkdownEditor, saveInlineEdit, clickToEditNotes, clickToEditSummary, autoSaveNotes, autoSaveSummary, sendChatMessage, isChatScrolledToBottom, scrollChatToBottom, startColumnResize, handleChatKeydown, seekAudio, seekAudioFromEvent, onPlayerVolumeChange, showToast, copyMessage, copyTranscription, copySummary, copyNotes, downloadSummary, downloadTranscript, downloadNotes, downloadChat, clearChat, downloadEventICS, downloadICS, formatEventDateTime, toggleInbox, toggleHighlight, toggleTranscriptionViewMode, reprocessTranscription, reprocessSummary, generateSummary, resetRecordingStatus, confirmReset, cancelReset, executeReset, openTranscriptionEditor, openTextEditorModal, closeTextEditorModal, saveTranscription, openAsrEditorModal, closeAsrEditorModal, saveAsrTranscription, adjustTime, filterSpeakers, openSpeakerSuggestions, closeSpeakerSuggestions, selectSpeaker, addSegment, removeSegment, showReprocessModal, reprocessType, reprocessRecording, cancelReprocess, executeReprocess, asrReprocessOptions, showSpeakerModal, showShareModal, recordingToShare, shareOptions, generatedShareLink, existingShareDetected, userShares, isLoadingShares, showSharesListModal, shareToDelete, showShareDeleteModal, confirmDeleteShare, cancelDeleteShare, speakerMap, speakerDisplayMap, modalSpeakers, regenerateSummaryAfterSpeakerUpdate, identifiedSpeakers, identifiedSpeakersInOrder, hasSpeakerNames, openSpeakerModal, closeSpeakerModal, saveSpeakerNames, highlightedTranscript, highlightedSpeaker, highlightSpeakerInTranscript, focusSpeaker, blurSpeaker, clearSpeakerHighlight, currentSpeakerGroupIndex, speakerGroups, navigateToNextSpeakerGroup, navigateToPrevSpeakerGroup, speakerSuggestions, loadingSuggestions, activeSpeakerInput, searchSpeakers, selectSpeakerSuggestion, closeSpeakerSuggestionsOnClick, autoIdentifySpeakers, isAutoIdentifying, formatDuration, openShareModal, closeShareModal, createShare, openSharesList, closeSharesList, updateShare, deleteShare, copyShareLink, startDraftRename, saveDraftRename, editingDraftId, editingDraftName, draftNameInput, draftRecordings, deleteDraft, continueDraftRecording, loadDrafts, // Recording Size Monitoring estimatedFileSize, fileSizeWarningShown, recordingQuality, actualBitrate, maxRecordingMB, sizeCheckInterval, updateFileSizeEstimate, startSizeMonitoring, stopSizeMonitoring, sumupResult, lastTimer, promptVisible, promptList, openPromptModal, closePromptModal, confirmPromptSelection, selectedPromptId, recommendPromptId, recommendPromptName, isMarkdown, renderedMarkdown, recommendPromptReason, showSpeaker, isEditingTranscription, editableText, enterEditMode, exitEditMode, }; }, delimiters: ["${", "}"], }); // Add t function as a global property BEFORE mounting so it's available in templates immediately app.config.globalProperties.t = safeT; // Also add tc for pluralization app.config.globalProperties.tc = (key, count, params = {}) => { if (!window.i18n || !window.i18n.tc) { return key; } return window.i18n.tc(key, count, params); }; // Provide t and tc to the app app.provide("t", safeT); app.provide("tc", (key, count, params = {}) => { if (!window.i18n || !window.i18n.tc) { return key; } return window.i18n.tc(key, count, params); }); // Mount the app app.mount("#app"); // Hide loading overlay after Vue is mounted and ready Vue.nextTick(() => { const overlay = document.querySelector(".app-loading-overlay"); if (overlay) { // Small delay to ensure everything is rendered setTimeout(() => { overlay.classList.add("fade-out"); setTimeout(() => { overlay.remove(); document.body.classList.remove("app-loading"); }, 300); }, 100); } else { document.body.classList.remove("app-loading"); } }); });