// ============================================================================= // 文件来源:从 src/main-old.js 拆分而来(Vue 应用主体) // 实时 ASR 链路拆到 ./asr/realtime-asr-pipeline.js; // 录音详情高级操作(重处理/转写编辑/说话人/状态/标记/轮询)拆到 // ./composables/useRecordingOperations.js。 // // 与原代码(main-old.js)相比的【行为差异】,需在迁移/回归时特别注意: // // A. 说话人/重处理相关状态改为“单一数据源 + 依赖注入”(2024 重构修复) // - 原代码:speakerMap / highlightedSpeaker / showSpeakerModal / // speakerSuggestions / regenerateSummaryAfterSpeakerUpdate / asrReprocessOptions // 等只在 main.js setup 内部定义一份,openSpeakerModal/saveSpeakerNames/ // highlightSpeakerInTranscript 等方法直接闭包读写同一份 ref。 // - 拆分初版:useRecordingOperations 内部又创建了一份同名 ref,导致 // main.js 的 processedTranscription / highlightedTranscript / handleClickAway // 读到的是 main.js 那份(空),而 composable 的方法写的是 composable 那份 // → 出现“名字不显示 / 高亮不渲染 / 点击外部不收起建议”4 个回归。 // - 现行做法(方案 B):这些 ref 统一在 main.js setup 顶部定义,作为 // 依赖注入传给 useRecordingOperations;composable 内部不再创建同名 ref, // 全部读写注入的那份。由此 processedTranscription(main.js)与 // openSpeakerModal(composable)操作同一份 speakerMap,4 个回归全部修复。 // - 副作用:regenerateSummaryAfterSpeakerUpdate 默认值由 true 改为 false // (对齐原 composable 默认,保持修复前实际生效行为);asrReprocessOptions // 由 reactive 改为 ref(对齐 composable 类型,模板自动解包,v-model 正常)。 // // B. resumeRecording 不再设置 resetTranscriptionFlag(时序变化,结果等价) // - 原代码:resume 时设 resetTranscriptionFlag=true,等下一条 ws 消息进来 // 时 getJsonMessage 再 clearRealtimePipeline()。 // - 新代码:resume 开头直接 resetRealtimeTranscript(),立刻清。 // - 结果等价,时序不同(更干净)。 // // C. continueDraftRecording 现在会调 resetRealtimeTranscript(更彻底) // - 原代码:加载草稿只重置 segmentRenderStartIndex=0。 // - 新代码:额外清空 pendingSentenceQueue 并 startNewEpoch。 // - 基本是改进,但若依赖 pending 队列残留需注意。 // // D. ASR 链路行为差异见 ./asr/realtime-asr-pipeline.js 文件头注释 // (ws 断连处理、stopRecording 关 Recorder、correct 新增 scene、 // wsconnecter getScene、appendInt16 性能、dispose 屏障)。 // // E. 隐式契约:以下 23 个 ref(speakerMap、highlightedSpeaker、showSpeakerModal、 // showResetModal、showReprocessModal、showTextEditorModal、showAsrEditorModal、 // editingTranscriptionContent、editingSegments、availableSpeakers、 // speakerDisplayMap、modalSpeakers、regenerateSummaryAfterSpeakerUpdate、 // speakerSuggestions、loadingSuggestions、activeSpeakerInput、isAutoIdentifying、 // currentSpeakerGroupIndex、speakerGroups、recordingToReset、reprocessType、 // reprocessRecording、asrReprocessOptions)必须在 useRecordingOperations(...) // 调用之前声明(否则 TDZ)。当前都满足(refs 在 ~237-300,调用在 ~1517)。 // 未来重构请勿把这些 ref 挪到调用点之后。 // ============================================================================= import * as Vue from "vue"; import "./csrf-refresh.js"; import "./i18n.js"; import { RealtimeAsrPipeline } from './asr/realtime-asr-pipeline.js'; import { EasyMDE, marked, assertIndexVendorGlobals } from './vendor-globals.js'; import { useRecordingOperations } from './composables/useRecordingOperations.js'; import * as recordingState from './composables/useRecordingState.js'; import { useSidebar } from './composables/useSidebar.js'; const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue; assertIndexVendorGlobals(); // let isfilemode = false; // Wait for the DOM to be fully loaded before mounting the Vue app //------------------- 页面启动与 Vue 初始化 start------------------ 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 realtimeAsrPipeline = null; let systemRealtimeAsrPipeline = null; //--------------------页面启动与 Vue 初始化 end------------------ //------------------- Vue 应用主体定义 start------------------ const app = createApp({ setup() { let isRec = false; //------------------- 响应式状态与实时转写基础状态 start------------------ // --- Core State --- // 新增说话人占位 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 - 已移至 useSidebar // --- 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); // searchDebounceTimer 已移至 useSidebar const searchDebounceTimer = ref(null); // --- Enhanced Search & Organization State --- // sortBy 已移至 useSidebar const selectedTagFilter = ref(null); // For filtering by clicked tag // --- UI State --- const browser = ref("unknown"); // isSidebarCollapsed 已移至 useSidebar 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 // --- Audio Recording State --- // editingDraftId, editingDraftName 已移至 useSidebar const draftNameInput = ref(null); // ============== 从 useRecordingState 导入录音/ASR 状态 ============== const { isRecording, isStoppingRecording, isPausingRecording, is_final, mediaRecorder, audioChunks, audioBlobURL, recordingTime, recordingInterval, canRecordAudio, canRecordSystemAudio, systemAudioSupported, systemAudioError, recordingNotes, showSystemAudioHelp, asrLanguage, asrMinSpeakers, asrMaxSpeakers, audioContext, analyser, micAnalyser, systemAnalyser, visualizer, micVisualizer, systemVisualizer, animationFrameId, recordingMode, activeStreams, estimatedFileSize, fileSizeWarningShown, recordingQuality, actualBitrate, maxRecordingMB, sizeCheckInterval, isPaused, currentDraftId, pausedDuration, pausedTranscription, draftRecordings, draftsExpanded, asrResult, asrResultOnline, asrResultOffline, showRecordingDisclaimerModal, pendingRecordingMode, cleanupRecording, resetASRState, } = recordingState; // 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); // 【行为差异 A】原 main-old.js 用 reactive({language:"", min_speakers:null, // max_speakers:null});这里改为 ref 并对齐 composable 默认(zh/1/10), // 以便作为依赖注入给 useRecordingOperations(composable 内部按 ref 访问 // .value)。模板 v-model="asrReprocessOptions.language" 在 ref 下自动解包, // 行为正常。 const asrReprocessOptions = ref({ language: "zh", min_speakers: 1, max_speakers: 10, }); const speakerMap = ref({}); // 【行为差异 A】原 main.js 顶部默认是 true,但模板实际用的是 // recordingOps.regenerateSummaryAfterSpeakerUpdate(composable 那份,默认 // false)。修复后统一为注入的这份,默认值对齐为 false,保持修复前实际 // 生效行为。 const regenerateSummaryAfterSpeakerUpdate = ref(false); const speakerSuggestions = ref({}); const loadingSuggestions = ref({}); const activeSpeakerInput = ref(null); // 以下 4 个 ref 原本只在 composable 内部存在,方案 B 改为 main.js 定义 // 并注入,确保 main.js 的 computed/handler 与 composable 方法同源。 const speakerDisplayMap = ref({}); const modalSpeakers = ref([]); const currentSpeakerGroupIndex = ref(-1); const speakerGroups = ref([]); // --- 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(""); // asrResult, asrResultOnline, asrResultOffline 从 useRecordingState 导入 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 systemLastTimer = ref(0); const urlmsg = ref("获取链接中..."); const urlinfo = ref(""); const zjloding = ref(false); const showSpeaker = ref(true); const selectedScene = ref((localStorage.getItem("selectedScene") || "cg").toLowerCase() === "yl" ? "yl" : "cg"); // --- 转录文本编辑状态 --- const isEditingTranscription = ref(false); const editableText = ref(""); const systemAsrResult = ref(""); const systemAsrResultOnline = ref(""); const systemAsrResultOffline = ref(""); const systemPausedTranscription = ref(""); const systemEditableText = ref(""); const systemAsrResultTextarea = ref(null); const systemAsrResultTextareaOnline = ref(null); const dualTranscriptContainer = ref(null); const dualTranscriptTopPercent = ref(66.67); const isResizingDualTranscript = ref(false); let dualTranscriptResizeElement = null; const fullRealtimeTranscription = computed(() => { return ( (asrResult.value || "") + (asrResultOffline.value || "") + (asrResultOnline.value || "") ); }); const fullSystemRealtimeTranscription = computed(() => { return ( (systemAsrResult.value || "") + (systemAsrResultOffline.value || "") + (systemAsrResultOnline.value || "") ); }); const isDualRealtimeTranscription = computed(() => recordingMode.value === "both"); const combinedRealtimeTranscription = computed(() => { if (!isDualRealtimeTranscription.value) { return fullRealtimeTranscription.value; } const micText = fullRealtimeTranscription.value || ""; const systemText = fullSystemRealtimeTranscription.value || ""; const parts = []; if (micText) { parts.push("[MIC]\n" + micText); } if (systemText) { parts.push("[SYSTEM]\n" + systemText); } return parts.join("\n\n"); }); function updateDualTranscriptSplit( clientY, container = dualTranscriptResizeElement || dualTranscriptContainer.value ) { if (!container) return; const rect = container.getBoundingClientRect(); if (!rect.height) return; const nextPercent = ((clientY - rect.top) / rect.height) * 100; dualTranscriptTopPercent.value = Math.min(80, Math.max(35, nextPercent)); } function stopDualTranscriptResize() { if (!isResizingDualTranscript.value) return; isResizingDualTranscript.value = false; dualTranscriptResizeElement = null; window.removeEventListener("mousemove", onDualTranscriptResizeMove); window.removeEventListener("mouseup", stopDualTranscriptResize); } function onDualTranscriptResizeMove(event) { if (!isResizingDualTranscript.value) return; updateDualTranscriptSplit(event.clientY); } function startDualTranscriptResize(event) { if (!isDualRealtimeTranscription.value) return; event.preventDefault(); dualTranscriptResizeElement = event.currentTarget?.closest?.(".dual-transcript-stack") || event.currentTarget?.parentElement || dualTranscriptContainer.value; isResizingDualTranscript.value = true; updateDualTranscriptSplit(event.clientY, dualTranscriptResizeElement); window.addEventListener("mousemove", onDualTranscriptResizeMove); window.addEventListener("mouseup", stopDualTranscriptResize); } function clearSystemRealtimeTranscript() { systemAsrResult.value = ""; systemAsrResultOnline.value = ""; systemAsrResultOffline.value = ""; systemPausedTranscription.value = ""; systemEditableText.value = ""; systemLastTimer.value = 0; } function getRealtimeTranscriptionForSave() { return combinedRealtimeTranscription.value || ""; } function getPrimaryRealtimeSourceKind() { if (recordingMode.value === "system") { return "system"; } return "mic"; } function syncRealtimeActiveState() { isRec = !!( realtimeAsrPipeline?.isActive() || systemRealtimeAsrPipeline?.isActive() ); } function getRealtimePipelines() { return [realtimeAsrPipeline, systemRealtimeAsrPipeline].filter(Boolean); } function createPipelineForSource(sourceKind, callbacks) { return new RealtimeAsrPipeline({ getMicStream: () => recordingState.micStream, getSystemStream: () => recordingState.systemStream, getPausedTranscription: callbacks.getPausedTranscription, setPausedTranscription: callbacks.setPausedTranscription, getShowSpeaker: () => showSpeaker.value, getConfigWords: () => configwords, getScene: () => selectedScene.value, getLastTimer: () => callbacks.getLastTimer ? callbacks.getLastTimer() : lastTimer.value || 0, setLastTimer: (value) => { if (callbacks.setLastTimer) { callbacks.setLastTimer(value); } else { lastTimer.value = value; } }, onMainTextChange: callbacks.onMainTextChange, onPreviewTextChange: callbacks.onPreviewTextChange, onOfflineTextChange: callbacks.onOfflineTextChange, onActiveChange: syncRealtimeActiveState, onError: (error) => { console.error(`Realtime ASR pipeline (${sourceKind}) error:`, error); }, sourceKind, }); } function startNewRealtimeEpoch(options = {}) { getRealtimePipelines().forEach((pipeline) => pipeline.startNewEpoch(options)); } function resetRealtimeTranscript(options = {}) { getRealtimePipelines().forEach((pipeline) => pipeline.resetTranscript(options)); if (options.clearTimestampBarrier || !systemRealtimeAsrPipeline) { clearSystemRealtimeTranscript(); } } function prepareRealtimeForResume() { getRealtimePipelines().forEach((pipeline) => pipeline.prepareForResume()); } function disposeRealtimeAsrPipeline() { getRealtimePipelines().forEach((pipeline) => pipeline.dispose()); realtimeAsrPipeline = null; systemRealtimeAsrPipeline = null; isRec = false; } function createRealtimeAsrPipeline() { disposeRealtimeAsrPipeline(); realtimeAsrPipeline = createPipelineForSource(getPrimaryRealtimeSourceKind(), { getPausedTranscription: () => pausedTranscription.value || "", setPausedTranscription: (text) => { pausedTranscription.value = text || ""; }, onMainTextChange: (text) => { asrResult.value = text || ""; }, onPreviewTextChange: (text) => { asrResultOnline.value = text || ""; }, onOfflineTextChange: (text) => { asrResultOffline.value = text || ""; }, }); if (recordingMode.value === "both") { systemRealtimeAsrPipeline = createPipelineForSource("system", { getPausedTranscription: () => systemPausedTranscription.value || "", setPausedTranscription: (text) => { systemPausedTranscription.value = text || ""; }, getLastTimer: () => systemLastTimer.value || 0, setLastTimer: (value) => { systemLastTimer.value = value; }, onMainTextChange: (text) => { systemAsrResult.value = text || ""; }, onPreviewTextChange: (text) => { systemAsrResultOnline.value = text || ""; }, onOfflineTextChange: (text) => { systemAsrResultOffline.value = text || ""; }, }); } else { clearSystemRealtimeTranscript(); } return realtimeAsrPipeline; } function ensureRealtimeAsrPipeline() { if (!realtimeAsrPipeline || (recordingMode.value === "both" && !systemRealtimeAsrPipeline)) { return createRealtimeAsrPipeline(); } if (recordingMode.value !== "both" && systemRealtimeAsrPipeline) { return createRealtimeAsrPipeline(); } return realtimeAsrPipeline; } function startRealtimeAsrPipelines() { ensureRealtimeAsrPipeline(); const started = getRealtimePipelines() .map((pipeline) => pipeline.start()) .some((result) => result === 1); syncRealtimeActiveState(); return started ? 1 : 0; } async function finishRealtimeAsrPipelines(timeoutMs = 20000) { const pipelines = getRealtimePipelines(); await Promise.allSettled(pipelines.map((pipeline) => pipeline.finish(timeoutMs))); syncRealtimeActiveState(); } function stopRealtimeAsrPipelines() { getRealtimePipelines().forEach((pipeline) => pipeline.stop()); syncRealtimeActiveState(); } function normalizeScene(scene) { return String(scene || "cg").trim().toLowerCase() === "yl" ? "yl" : "cg"; } function setSelectedScene(scene) { const nextScene = normalizeScene(scene); if (isRecording.value || isPaused.value || isStoppingRecording.value || isRec === true) { showToast("录音中无法切换场景", "error"); return; } selectedScene.value = nextScene; localStorage.setItem("selectedScene", nextScene); } async function waitForRealtimeCorrections(timeoutMs = 8000) { const pipelines = getRealtimePipelines(); if (pipelines.length === 0) { return 0; } const count = pipelines.reduce( (total, pipeline) => total + pipeline.getPendingCorrectionCount(), 0 ); if (count > 0) { console.log('Waiting for ' + count + ' ASR corrections...'); } const results = await Promise.all( pipelines.map((pipeline) => pipeline.waitForCorrections(timeoutMs)) ); const remaining = results.reduce((total, value) => total + value, 0); if (remaining > 0) { console.warn('Timed out with ' + remaining + ' ASR corrections still pending'); } return remaining; } // 暂停录音时调用:把所有未矫正的预览文本立即拿去矫正并加入 segmentList async function finalizeRemainingSentences(timeoutMs = 8000) { await Promise.all( getRealtimePipelines().map((pipeline) => pipeline.finalizeRemainingSentences(timeoutMs) ) ); } //--------------------响应式状态与实时转写基础状态 end------------------ //------------------- 计算属性与派生展示数据 start------------------ // --- Computed Properties --- const isMobileScreen = computed(() => { return windowWidth.value < 1024; }); // datePresetOptions 已移至 useSidebar 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; }); // highlightedTranscript 的高亮/转义/着色实现统一由 useRecordingOperations // 提供(recordingOps.highlightedTranscript),模板暴露的也是那份; // 此处不再保留重复实现,避免双份 computed 读到不同 speakerMap 导致显示不一致。 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; }); // groupedRecordings 已移至 useSidebar 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; }); // hasSpeakerNames 统一由 useRecordingOperations 提供(recordingOps.hasSpeakerNames), // 它读的 speakerMap 与本文件注入的是同一份 ref,避免双份 computed 结果不一致。 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", })), }; } }); //--------------------计算属性与派生展示数据 end------------------ //------------------- 主题与配色配置 start------------------ // --- 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", }, ], }; //--------------------主题与配色配置 end------------------ //------------------- 通用工具方法与格式化逻辑 start------------------ // --- 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; }; //--------------------通用工具方法与格式化逻辑 end------------------ //------------------- 录音体积监控逻辑 start------------------ // --- 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; } }; //--------------------录音体积监控逻辑 end------------------ //------------------- 总结提示词与外部分析流程 start------------------ 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}`); } }; //--------------------总结提示词与外部分析流程 end------------------ // 日期分组与排序工具已移至 useSidebar //------------------- 录音详情高级操作 start------------------ const resetCurrentFileProcessingState = () => { if (pollInterval.value) clearInterval(pollInterval.value); pollInterval.value = null; currentlyProcessingFile.value = null; processingProgress.value = 0; processingMessage.value = ""; }; // ================== 使用侧边栏组合式函数 ================== // useSidebar 调用已移至 loadRecordings 定义之后(避免 TDZ 错误, // 因为 useSidebar 内部的 loadMoreRecordings 依赖 loadRecordings) // 使用组合式函数管理录音高级操作 // 【行为差异 A / 方案 B】说话人/重处理/编辑相关共享状态统一由 main.js // 顶部定义并注入 composable,建立单一数据源,避免 composable 内部再创建 // 一份导致 main.js 的 computed/handler 读到空对象(拆分初版的 4 个回归 // 根因)。注意:以下注入的 ref 必须在此调用之前已声明(见文件头 E)。 const recordingOps = useRecordingOperations({ recordings, selectedRecording, selectedTab, uploadQueue, currentlyProcessingFile, processingProgress, processingMessage, progressPopupMinimized, progressPopupClosed, identifiedSpeakers, processedTranscription, showToast, setGlobalError, resetCurrentFileProcessingState, // 说话人/重处理/编辑相关共享状态(单一数据源,避免 composable 内部 // 再创建一份导致 main.js 的 computed/handler 读到空对象) speakerMap, highlightedSpeaker, showSpeakerModal, showResetModal, showReprocessModal, showTextEditorModal, showAsrEditorModal, editingTranscriptionContent, editingSegments, availableSpeakers, speakerDisplayMap, modalSpeakers, regenerateSummaryAfterSpeakerUpdate, speakerSuggestions, loadingSuggestions, activeSpeakerInput, isAutoIdentifying, currentSpeakerGroupIndex, speakerGroups, recordingToReset, reprocessType, reprocessRecording, asrReprocessOptions, }); // 为了兼容模板中的直接调用,保留这些函数引用 const reprocessTranscription = recordingOps.reprocessTranscription; const reprocessSummary = recordingOps.reprocessSummary; const generateSummary = recordingOps.generateSummary; const resetRecordingStatus = recordingOps.resetRecordingStatus; const confirmReprocess = recordingOps.confirmReprocess; const cancelReprocess = recordingOps.cancelReprocess; const executeReprocess = recordingOps.executeReprocess; const openTranscriptionEditor = recordingOps.openTranscriptionEditor; const openTextEditorModal = recordingOps.openTextEditorModal; const closeTextEditorModal = recordingOps.closeTextEditorModal; const saveTranscription = recordingOps.saveTranscription; const openAsrEditorModal = recordingOps.openAsrEditorModal; const closeAsrEditorModal = recordingOps.closeAsrEditorModal; const saveAsrTranscription = recordingOps.saveAsrTranscription; const adjustTime = recordingOps.adjustTime; const filterSpeakers = recordingOps.filterSpeakers; const openSpeakerSuggestions = recordingOps.openSpeakerSuggestions; const closeSpeakerSuggestions = recordingOps.closeSpeakerSuggestions; const selectSpeaker = recordingOps.selectSpeaker; const addSegment = recordingOps.addSegment; const removeSegment = recordingOps.removeSegment; const confirmReset = recordingOps.confirmReset; const cancelReset = recordingOps.cancelReset; const executeReset = recordingOps.executeReset; const searchSpeakers = recordingOps.searchSpeakers; const selectSpeakerSuggestion = recordingOps.selectSpeakerSuggestion; const closeSpeakerSuggestionsOnClick = recordingOps.closeSpeakerSuggestionsOnClick; const openSpeakerModal = recordingOps.openSpeakerModal; const closeSpeakerModal = recordingOps.closeSpeakerModal; const saveSpeakerNames = recordingOps.saveSpeakerNames; const highlightSpeakerInTranscript = recordingOps.highlightSpeakerInTranscript; const focusSpeaker = recordingOps.focusSpeaker; const blurSpeaker = recordingOps.blurSpeaker; const clearSpeakerHighlight = recordingOps.clearSpeakerHighlight; const navigateToNextSpeakerGroup = recordingOps.navigateToNextSpeakerGroup; const navigateToPrevSpeakerGroup = recordingOps.navigateToPrevSpeakerGroup; const autoIdentifySpeakers = recordingOps.autoIdentifySpeakers; const toggleInbox = recordingOps.toggleInbox; const toggleHighlight = recordingOps.toggleHighlight; //--------------------录音详情高级操作 end------------------ //------------------- 深色模式与主题切换 start------------------ // --- 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); } }; }); } //--------------------深色模式与主题切换 end------------------ //------------------- 侧边栏与视图切换控制 start------------------ // --- Sidebar Toggle --- // toggleSidebar 已移至 useSidebar //--------------------侧边栏与视图切换控制 end------------------ //------------------- 页面视图管理 start------------------ // --- View Management --- const switchToUploadView = async () => { if (currentView.value === "recording" && isRec === true) { const confirmed = await confirmStopRecording(); if (!confirmed) return; // 清空所有录音相关状态 recordingState.discardRecordingState(); resetRealtimeTranscript({ clearTimestampBarrier: true }); currentView.value = "upload"; selectedRecording.value = null; if (isMobileScreen.value) { sidebar.isSidebarCollapsed.value = true; } } else { // 即使不在录音中,也清空状态以准备新录音 recordingState.discardRecordingState(); resetRealtimeTranscript({ clearTimestampBarrier: true }); currentView.value = "upload"; selectedRecording.value = null; if (isMobileScreen.value) { sidebar.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) { sidebar.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) { sidebar.isSidebarCollapsed.value = true; } } }; //--------------------页面视图管理 end------------------ //------------------- 文件上传与上传队列 start------------------ // --- 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 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.displayName = data.title || data.original_filename || fileItem.file.name; 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); }; //--------------------文件上传与上传队列 end------------------ //------------------- 录音与标签数据加载 start------------------ // --- 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) - 已移至 useSidebar // ================== 使用侧边栏组合式函数 ================== // 管理与侧边栏相关的状态:筛选、排序、草稿重命名、侧边栏折叠等 // 注意:必须在 loadRecordings 定义之后调用,因为 useSidebar 内部 // 的 loadMoreRecordings 依赖 loadRecordings(否则 TDZ 错误)。 // 注意:传入 safeT 而非 t,因为 t = safeT 在后面(~4347行)才定义。 const sidebar = useSidebar({ t: safeT, filteredRecordings, loadRecordings, showToast, currentPage, hasNextPage, isLoadingMore, searchQuery, availableTags, draftNameInput, }); // 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) ); }); // filterByTag, clearTagFilter, buildSearchQuery, applyAdvancedFilters, clearAllFilters // 已移至 useSidebar 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}`); } }; //--------------------录音与标签数据加载 end------------------ // ============== 录音控制与实时采集(使用 useRecordingState 工具函数) ============== /** * 免责声明检查 */ debugger; const startRecordingWithDisclaimer = async (mode = "microphone") => { if (recordingDisclaimer.value?.trim()) { recordingState.showDisclaimer(mode); } else { await startRecordingActual(mode); } }; const acceptRecordingDisclaimer = async () => { const mode = recordingState.acceptDisclaimer(); if (mode) await startRecordingActual(mode); }; const cancelRecordingDisclaimer = () => { recordingState.cancelDisclaimer(); }; const startRecording = startRecordingWithDisclaimer; /** * 实际开始录音 */ const startRecordingActual = async (mode = "microphone") => { recordingMode.value = mode; try { // 加载标签 if (availableTags.value.length === 0) { await loadTags(); } // 初始化录音状态 recordingState.initRecordingState(); selectedTags.value = []; asrLanguage.value = ""; asrMinSpeakers.value = ""; asrMaxSpeakers.value = ""; isEditingTranscription.value = false; resetRealtimeTranscript({ clearTimestampBarrier: true }); let combinedStream = null; // 获取麦克风流 if (mode === "microphone" || mode === "both") { if (!canRecordAudio.value) { throw new Error("Microphone recording is not supported"); } await recordingState.getMicrophoneStream(); showToast("Microphone access granted", "fa-microphone"); } // 获取系统音频流 if (mode === "system" || mode === "both") { if (!canRecordSystemAudio.value) { throw new Error("System audio recording is not supported"); } try { await recordingState.getSystemAudioStream(); showToast("System audio access granted", "fa-desktop"); } catch (err) { if (mode === "system") { throw err; } else { showToast( "System audio denied, using microphone only", "fa-exclamation-triangle" ); mode = "microphone"; recordingMode.value = mode; recordingState.setSystemStream(null); } } } // 合并或选择音频流 if (recordingState.micStream && recordingState.systemStream) { try { combinedStream = recordingState.combineAudioStreams(recordingState.micStream, recordingState.systemStream); showToast("Recording both microphone and system audio", "fa-microphone"); } catch (error) { console.error("Failed to combine audio streams:", error); combinedStream = recordingState.systemStream; showToast( "Failed to combine audio, using system audio only", "fa-exclamation-triangle" ); } } else if (recordingState.systemStream) { combinedStream = recordingState.createAudioOnlyStream(recordingState.systemStream); showToast("Recording system audio only", "fa-desktop"); } else if (recordingState.micStream) { combinedStream = recordingState.micStream; showToast("Recording microphone only", "fa-microphone"); } else { throw new Error("No audio streams available for recording."); } // 使用工具函数创建 MediaRecorder recordingState.createMediaRecorderWithFallback(combinedStream, (bitrate) => { actualBitrate.value = bitrate; showToast( bitrate <= 32000 ? "Recording: Optimized (32kbps Opus)" : bitrate <= 64000 ? "Recording: Good quality (64kbps)" : "Recording with browser default settings", bitrate <= 64000 ? "fa-compress-alt" : "fa-microphone", 3000 ); }); 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 = []; recordingState.setMicStream(null); recordingState.setSystemStream(null); if (audioContext.value) { audioContext.value .close() .catch((e) => console.error("Error closing AudioContext:", e)); audioContext.value = null; } cancelAnimationFrame(animationFrameId.value); recordingState.stopRecordingTimer(); }; // --- 设置可视化器(使用工具函数)--- if (mode === "both" && recordingState.micStream && recordingState.systemStream) { recordingState.setupDualVisualizer(recordingState.micStream, recordingState.systemStream); } else { // 根据 recordingMode 选择正确的流,避免旧流残留导致选错 const visualizerStream = mode === "microphone" ? recordingState.micStream : recordingState.systemStream; if (visualizerStream) { recordingState.setupSingleVisualizer(visualizerStream); } } // 开始录音和计时 mediaRecorder.value.start(); isRecording.value = true; recordingState.startRecordingTimer(); // 开始大小监控 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; } } await finishRealtimeAsrPipelines(20000); // Wait for MediaRecorder to emit the final blob. await new Promise(resolve => setTimeout(resolve, 500)); // 如果是草稿模式,上传最后一个片段但不要 finalize 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', getRealtimeTranscriptionForSave()); 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) { sidebar.isSidebarCollapsed.value = true; } } finally { isStoppingRecording.value = false; } }; // 暂停录音 const pauseRecording = async () => { if (!isRecording.value || isPaused.value || isPausingRecording.value) return; isPausingRecording.value = true; try { // 首先停止音频数据发送,防止向已关闭的 WebSocket 发送数据 isRec = false; // 停止实时 ASR 管线 stopRealtimeAsrPipelines(); // 停止当前 MediaRecorder if (mediaRecorder.value && mediaRecorder.value.state === 'recording') { mediaRecorder.value.stop(); } // 立即停止波形图动画(不等 onstop 回调,避免 resumeRecording 创建的 // MediaRecorder 的 onstop 回调缺少 cancelAnimationFrame 导致波形图 // 在 await 期间继续运行) cancelAnimationFrame(animationFrameId.value); animationFrameId.value = null; // 等待 MediaRecorder 生成最终数据 await new Promise(resolve => setTimeout(resolve, 500)); // 如果处于编辑模式,先退出编辑模式 if (isEditingTranscription.value) { exitEditMode(); } // 把所有未矫正的预览文本立即拿去矫正,等待完成后加入 segmentList // 这样暂停时所有已识别的文字都成为正式转写,恢复后不会丢失 await finalizeRemainingSentences(8000); // 创建草稿(如果是第一次暂停) 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); // 发送完整转录文本(正式转写 + 预览文本),与显示框 fullRealtimeTranscription 一致 const fullTranscription = getRealtimeTranscriptionForSave(); 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; // 保存完整的显示文本(正式转写 + 预览文本),恢复录音时作为基准 // asrResult 是已完成矫正的正式文本,asrResultOnline/asrResultOffline 是尚未完成矫正的预览文本 // 两者都必须保存,否则恢复后预览部分的文字会丢失 pausedTranscription.value = fullRealtimeTranscription.value || ''; systemPausedTranscription.value = fullSystemRealtimeTranscription.value || ''; isRecording.value = false; // 停止计时器和动画 recordingState.stopRecordingTimer(); cancelAnimationFrame(animationFrameId.value); stopSizeMonitoring(); // 停止所有媒体流 activeStreams.value.forEach(stream => { stream.getTracks().forEach(track => track.stop()); }); activeStreams.value = []; recordingState.setMicStream(null); recordingState.setSystemStream(null); showToast('录音已暂停', 'fa-pause'); // 刷新草稿列表 loadDrafts(); } catch (err) { console.error('Error pausing recording:', err); setGlobalError(`暂停录音失败: ${err.message}`); } finally { isPausingRecording.value = false; } }; // 恢复录音 const resumeRecording = async () => { if (!isPaused.value || !currentDraftId.value) return; try { // 重置音频块 audioChunks.value = []; // 准备恢复录音:保留历史转写数据(segmentList包含说话人、时间戳等元数据) // 只清空内部缓冲区准备接收新数据,新片段会从segmentList末尾继续追加 prepareRealtimeForResume(); isEditingTranscription.value = false; let combinedStream = null; const mode = recordingMode.value; // 重新获取媒体流 if (mode === 'microphone' || mode === 'both') { await recordingState.getMicrophoneStream(); } if (mode === 'system' || mode === 'both') { try { await recordingState.getSystemAudioStream(); } catch (err) { if (mode === 'system') throw err; showToast('系统音频权限被拒绝,仅使用麦克风', 'fa-exclamation-triangle'); recordingMode.value = 'microphone'; } } // 组合音频流 if (recordingState.micStream && recordingState.systemStream) { combinedStream = recordingState.combineAudioStreams(recordingState.micStream, recordingState.systemStream); } else if (recordingState.systemStream) { combinedStream = recordingState.createAudioOnlyStream(recordingState.systemStream); } else if (recordingState.micStream) { combinedStream = recordingState.micStream; } else { throw new Error('无可用音频流'); } // 创建新的 MediaRecorder recordingState.createMediaRecorderWithFallback(combinedStream); 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 (mode === 'both' && recordingState.micStream && recordingState.systemStream) { recordingState.setupDualVisualizer(recordingState.micStream, recordingState.systemStream); } else { const visualizerStream = recordingState.micStream || recordingState.systemStream; if (visualizerStream) { recordingState.setupSingleVisualizer(visualizerStream); } } // 开始录音 mediaRecorder.value.start(); isPaused.value = false; isRecording.value = true; // 恢复计时器(从暂停时间继续) recordingInterval.value = setInterval(() => recordingTime.value++, 1000); // 恢复可视化 drawVisualizers(); startSizeMonitoring(); // 恢复实时 ASR 管线 console.log('Resuming realtime ASR pipeline...'); asrResultOnline.value = ''; const ret = startRealtimeAsrPipelines(); if (ret === 1) { isRec = true; } 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 || ''; // 【行为差异 C】原 main-old.js 加载草稿只重置 segmentRenderStartIndex=0; // 新代码额外调 resetRealtimeTranscript(),会清空 pendingSentenceQueue // 并 startNewEpoch。更彻底(避免上一会话 pending 残留混入),基本是改进, // 但若依赖 pending 队列残留需注意。 resetRealtimeTranscript({ clearTimestampBarrier: true }); // 设置暂停状态 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 = ''; resetRealtimeTranscript({ clearTimestampBarrier: true }); } return true; } else { throw new Error(data.error); } } catch (err) { console.error('Error deleting draft:', err); setGlobalError(`删除草稿失败: ${err.message}`); return false; } }; // 重命名草稿 (startDraftRename, saveDraftRename) 已移至 useSidebar 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 = ''; resetRealtimeTranscript({ clearTimestampBarrier: true }); 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) { sidebar.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 = ""; }; /** * 绘制单轨音频波形图(频谱可视化) * * 原理: * 1. AnalyserNode 从音频流中实时提取频率数据(FFT分析) * - fftSize = 256,所以 frequencyBinCount = 128 * - 数据范围 0-255,表示各频率点的强度(0=静音,255=最大音量) * - 数组索引 0~127 对应 低频 → 高频 * * 2. 使用 Canvas 2D 绘制柱状图 * - 每根柱子代表一个频率点 * - 柱子高度 = 频率强度 / 2.5(缩放适应画布) * - 柱子从底部向上绘制(y = HEIGHT - barHeight) * * 3. 视觉效果 * - 使用 CSS 变量获取主题色,适配深浅模式 * - 渐变填充:顶部主题色 → 中间悬停色 → 底部透明 * * 数据流向: * MediaStream → AnalyserNode → getByteFrequencyData() → Uint8Array → Canvas fillRect() * * @param {AnalyserNode} analyserNode - Web Audio API 频谱分析节点 * @param {HTMLCanvasElement} canvasElement - 波形图画布 */ const drawSingleVisualizer = (analyserNode, canvasElement) => { if (!analyserNode || !canvasElement) return; // 获取频谱数据 // bufferLength = 128 (fftSize/2),每个元素对应一个频率段的音量 const bufferLength = analyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); // 将当前时刻的频率数据填充到数组,值范围 0-255 // 0 = 该频率无声音,255 = 该频率声音最大 // dataArray[0] = 最低频,dataArray[127] = 最高频 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; // 当前柱子的x坐标 // 从 CSS 变量获取主题色,实现深浅模式自适应 const buttonColor = getComputedStyle(document.documentElement) .getPropertyValue("--bg-button") .trim(); const buttonHoverColor = getComputedStyle(document.documentElement) .getPropertyValue("--bg-button-hover") .trim(); // 创建从上到下的渐变色 const gradient = canvasCtx.createLinearGradient(0, 0, 0, HEIGHT); if (isDarkMode.value) { gradient.addColorStop(0, buttonColor); gradient.addColorStop(0.6, buttonHoverColor); gradient.addColorStop(1, "rgba(0, 0, 0, 0.2)"); } else { 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++) { // 将 0-255 的数据值映射为画布高度 // 除以 2.5 是为了让波形不会顶到画布顶部,留有余量 barHeight = dataArray[i] / 2.5; canvasCtx.fillStyle = gradient; // fillRect(x, y, width, height) - 从 y 位置向下绘制 // 因为 Canvas 坐标系 y=0 在顶部,所以从 HEIGHT-barHeight 开始画到底部 canvasCtx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight); x += barWidth + 2; // 下一个柱子的位置(柱子宽 + 间隙) } }; /** * 波形图动画循环控制器 * * 原理: * 使用 requestAnimationFrame 实现约 60fps 的流畅动画 * 每帧调用 drawSingleVisualizer 重新绘制频谱柱状图 * * 录音模式支持: * - 单轨模式(mic/system):使用单个 analyser + visualizer canvas * - 双轨模式(both):同时使用 micAnalyser + systemAnalyser 分别绘制两个波形图 * * 生命周期: * 1. 开始录音时调用 drawVisualizers() 启动动画循环 * 2. 每帧通过 requestAnimationFrame(drawVisualizers) 递归调用 * 3. 停止/暂停录音时 isRecording.value = false,动画停止 * * 注意: * - 必须使用 requestAnimationFrame 而非 setInterval,以保证与浏览器刷新率同步 * - 动画 ID 保存在 animationFrameId.value,用于暂停时 cancelAnimationFrame */ const drawVisualizers = () => { // 录音停止时清理动画帧并退出 if (!isRecording.value) { if (animationFrameId.value) { cancelAnimationFrame(animationFrameId.value); animationFrameId.value = null; } return; } // 请求下一帧动画,约 16.7ms 后再次执行(60fps) // 这是浏览器推荐的高性能动画方式,会自动与屏幕刷新率同步 animationFrameId.value = requestAnimationFrame(drawVisualizers); // 根据录音模式决定绘制单轨还是双轨波形图 if (recordingMode.value === "both") { // 双轨模式:同时显示麦克风和系统音频波形(分上下两个 canvas) drawSingleVisualizer(micAnalyser.value, micVisualizer.value); drawSingleVisualizer(systemAnalyser.value, systemVisualizer.value); } else { // 单轨模式:只显示当前录制的音源波形 drawSingleVisualizer(analyser.value, visualizer.value); } }; //--------------------录音控制与实时采集 end------------------ //------------------- 录音保存与详情管理 start------------------ // --- 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(); } }; //--------------------录音保存与详情管理 end------------------ //------------------- 详情页内联编辑 start------------------ // --- 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"); } }; // 点击编辑icon 编辑摘要 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}`); } }; //--------------------详情页内联编辑 end------------------ //------------------- 聊天问答功能 start------------------ // --- 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(); } } }; //--------------------聊天问答功能 end------------------ //------------------- 分栏拖拽与尺寸持久化 start------------------ // --- 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(); }; //--------------------分栏拖拽与尺寸持久化 end------------------ //------------------- 聊天输入交互 start------------------ // --- 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(); } } }; //--------------------聊天输入交互 end------------------ //------------------- 音频播放器辅助逻辑 start------------------ // --- 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); }; //--------------------音频播放器辅助逻辑 end------------------ //------------------- 国际化辅助方法 start------------------ // --- 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); } } }; //--------------------国际化辅助方法 end------------------ //------------------- Toast 提示与复制下载反馈 start------------------ // --- Toast Notifications --- function 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}`); } }; //--------------------Toast 提示与复制下载反馈 end------------------ //------------------- 响应式监听与界面联动 start------------------ // --- 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(); } } }); }); } } }); // 会议记录页面 //------------------- 录音视图切换与实时 ASR 管线 start------------------ watch(currentView, (newView) => { if (newView === 'recording') { nextTick(() => { initializeRecordingMarkdownEditor(); getBaseInfo(); is_final.value = false; asrResultOnline.value = ''; createRealtimeAsrPipeline(); if (!isPaused.value) { startRealtimeAsrPipelines(); } 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 { disposeRealtimeAsrPipeline(); analysisResult.value = ''; asrResult.value = ''; pausedTranscription.value = ''; // 清空暂停时的转录文本 clearSystemRealtimeTranscript(); isEditingTranscription.value = false; is_final.value = false; if (recordingMarkdownEditorInstance.value) { recordingMarkdownEditorInstance.value.toTextArea(); recordingMarkdownEditorInstance.value = null; } } }); //--------------------录音视图切换与实时 ASR 管线 end------------------ // 监听内容变化 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, systemAsrResultTextarea.value, systemAsrResultTextareaOnline.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(fullSystemRealtimeTranscription, () => { 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); }); // 自动应用筛选的监听器已移至 useSidebar //--------------------响应式监听与界面联动 end------------------ //------------------- 运行时配置加载 start------------------ // --- 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); } }; //--------------------运行时配置加载 end------------------ //------------------- 录音格式兼容检测 start------------------ // --- 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; }; //--------------------录音格式兼容检测 end------------------ //------------------- 系统音频能力检测 start------------------ // --- 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"; } }; //--------------------系统音频能力检测 end------------------ //------------------- 生命周期初始化 start------------------ // --- 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); window.addEventListener("beforeunload", stopDualTranscriptResize); }); //--------------------生命周期初始化 end------------------ //------------------- 实时转写手动编辑模式 start------------------ // --- 转录文本编辑功能 --- // 用户手动编辑后,以编辑结果为准,旧的实时结果不再回灌。 let editModeOriginalText = ""; function enterEditMode() { editModeOriginalText = fullRealtimeTranscription.value || ''; editableText.value = editModeOriginalText; systemEditableText.value = fullSystemRealtimeTranscription.value || ''; realtimeAsrPipeline?.enterEditMode(editModeOriginalText); systemRealtimeAsrPipeline?.enterEditMode(systemEditableText.value); isEditingTranscription.value = true; } function exitEditMode() { const nextText = editableText.value ?? ''; const nextSystemText = systemEditableText.value ?? ''; isEditingTranscription.value = false; if (realtimeAsrPipeline) { realtimeAsrPipeline.applyEditedText(nextText); } else { pausedTranscription.value = nextText; asrResult.value = nextText; asrResultOnline.value = ''; asrResultOffline.value = ''; } if (systemRealtimeAsrPipeline) { systemRealtimeAsrPipeline.applyEditedText(nextSystemText); } else { systemPausedTranscription.value = nextSystemText; systemAsrResult.value = nextSystemText; systemAsrResultOnline.value = ''; systemAsrResultOffline.value = ''; } editModeOriginalText = nextText; } //--------------------实时转写手动编辑模式 end------------------ //------------------- 模板暴露与事件出口 start------------------ return { // Core State currentView, dragover, recordings, selectedRecording, selectedTab, searchQuery, isLoadingRecordings, globalError, maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, chunkingLimitDisplay, sortBy: sidebar.sortBy, showAdvancedFilters: sidebar.showAdvancedFilters, filterTags: sidebar.filterTags, filterDateRange: sidebar.filterDateRange, filterDatePreset: sidebar.filterDatePreset, filterTextQuery: sidebar.filterTextQuery, // Pagination State currentPage, perPage, totalRecordings, totalPages, hasNextPage, hasPrevPage, isLoadingMore, // UI State browser, isSidebarCollapsed: sidebar.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: sidebar.editingDraftId, editingDraftName: sidebar.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, isPausingRecording, currentDraftId, showRecordingControls, pausedDuration, pausedTranscription, draftRecordings, draftsExpanded, pauseRecording, resumeRecording, loadDrafts, continueDraftRecording, deleteDraft, // Modal State showEditModal, showDeleteModal, showResetModal: recordingOps.showResetModal, editingRecording, recordingToDelete, showEditTagsModal, selectedNewTagId, tagSearchFilter, filteredAvailableTagsForModal, editRecordingTags, closeEditTagsModal, addTagToRecording, removeTagFromRecording, getRecordingTags, getAvailableTagsForRecording, filterByTag: sidebar.filterByTag, clearTagFilter: sidebar.clearTagFilter, applyAdvancedFilters: sidebar.applyAdvancedFilters, clearAllFilters: sidebar.clearAllFilters, showTextEditorModal: recordingOps.showTextEditorModal, showAsrEditorModal: recordingOps.showAsrEditorModal, editingTranscriptionContent: recordingOps.editingTranscriptionContent, editingSegments: recordingOps.editingSegments, availableSpeakers: recordingOps.availableSpeakers, // Inline Editing editingParticipants, editingMeetingDate, editingSummary, editingNotes, // Markdown Editor notesMarkdownEditor, markdownEditorInstance, summaryMarkdownEditor, summaryMarkdownEditorInstance, recordingNotesEditor, // Transcription transcriptionViewMode, legendExpanded, highlightedSpeaker: recordingOps.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: sidebar.groupedRecordings, activeRecordingMetadata, datePresetOptions: sidebar.datePresetOptions, languageOptions, // Color Schemes colorSchemes, asrResult, asrResultOnline, asrResultOffline, fullRealtimeTranscription, fullSystemRealtimeTranscription, isDualRealtimeTranscription, systemEditableText, dualTranscriptContainer, dualTranscriptTopPercent, startDualTranscriptResize, asrTextarea, asrResultTextarea, asrResultTextareaOnline, systemAsrResultTextarea, systemAsrResultTextareaOnline, 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: sidebar.toggleSidebar, switchToUploadView, selectRecording, handleDragOver, handleDragLeave, handleDrop, handleFileSelect, addFilesToQueue, startRecording, stopRecording, uploadRecordedAudio, discardRecording, downloadRecordedAudio, loadRecordings, loadMoreRecordings: sidebar.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: recordingOps.showReprocessModal, reprocessType: recordingOps.reprocessType, reprocessRecording: recordingOps.reprocessRecording, cancelReprocess, executeReprocess, asrReprocessOptions: recordingOps.asrReprocessOptions, showSpeakerModal: recordingOps.showSpeakerModal, showShareModal, recordingToShare, shareOptions, generatedShareLink, existingShareDetected, userShares, isLoadingShares, showSharesListModal, shareToDelete, showShareDeleteModal, confirmDeleteShare, cancelDeleteShare, speakerMap: recordingOps.speakerMap, speakerDisplayMap: recordingOps.speakerDisplayMap, modalSpeakers: recordingOps.modalSpeakers, regenerateSummaryAfterSpeakerUpdate: recordingOps.regenerateSummaryAfterSpeakerUpdate, identifiedSpeakers, identifiedSpeakersInOrder: recordingOps.identifiedSpeakersInOrder, hasSpeakerNames: recordingOps.hasSpeakerNames, openSpeakerModal, closeSpeakerModal, saveSpeakerNames, highlightedTranscript: recordingOps.highlightedTranscript, highlightedSpeaker: recordingOps.highlightedSpeaker, highlightSpeakerInTranscript, focusSpeaker, blurSpeaker, clearSpeakerHighlight, currentSpeakerGroupIndex: recordingOps.currentSpeakerGroupIndex, speakerGroups: recordingOps.speakerGroups, navigateToNextSpeakerGroup, navigateToPrevSpeakerGroup, speakerSuggestions: recordingOps.speakerSuggestions, loadingSuggestions: recordingOps.loadingSuggestions, activeSpeakerInput: recordingOps.activeSpeakerInput, searchSpeakers, selectSpeakerSuggestion, closeSpeakerSuggestionsOnClick, autoIdentifySpeakers, isAutoIdentifying: recordingOps.isAutoIdentifying, formatDuration, openShareModal, closeShareModal, createShare, openSharesList, closeSharesList, updateShare, deleteShare, copyShareLink, startDraftRename: sidebar.startDraftRename, saveDraftRename: sidebar.saveDraftRename, editingDraftId: sidebar.editingDraftId, editingDraftName: sidebar.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, selectedScene, setSelectedScene, showSpeaker, isEditingTranscription, editableText, enterEditMode, exitEditMode, }; }, delimiters: ["${", "}"], }); //--------------------模板暴露与事件出口 end------------------ //--------------------Vue 应用主体定义 end------------------ //------------------- Vue 全局注入与挂载 start------------------ // 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"); } }); //--------------------Vue 全局注入与挂载 end------------------ });