6783 lines
248 KiB
JavaScript
6783 lines
248 KiB
JavaScript
// =============================================================================
|
||
// 文件来源:从 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 = `
|
||
<div style="font-size:16px;font-weight:600;margin-bottom:10px;">
|
||
确认停止录音
|
||
</div>
|
||
<div style="font-size:14px;color:#d1d5db;margin-bottom:20px;line-height:1.6;">
|
||
当前正在录音,切换页面将会停止录音,是否继续?
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;gap:12px;">
|
||
<button id="confirm-cancel"
|
||
style="padding:6px 14px;border-radius:6px;border:1px solid #4b5563;background:#374151;color:#fff;cursor:pointer;">
|
||
继续录音
|
||
</button>
|
||
<button id="confirm-ok"
|
||
style="padding:6px 14px;border-radius:6px;border:none;background:#ef4444;color:#fff;cursor:pointer;">
|
||
停止并切换
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `<span class="text-red-500">Error: ${error.message}</span>`;
|
||
} else {
|
||
chatMessages.value.push({
|
||
role: "assistant",
|
||
content: `Error: ${error.message}`,
|
||
html: `<span class="text-red-500">Error: ${error.message}</span>`,
|
||
});
|
||
}
|
||
} 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 = `<i class="fas ${icon}"></i> ${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 = '<i class="fas fa-check"></i>';
|
||
|
||
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 = `
|
||
<div class="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-md w-full mx-4">
|
||
<h3 class="text-lg font-semibold mb-4">Select Template</h3>
|
||
<div class="space-y-2 max-h-60 overflow-y-auto">
|
||
${templates
|
||
.map(
|
||
(t) => `
|
||
<button class="template-option w-full text-left p-3 rounded border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] ${
|
||
t.is_default
|
||
? "ring-2 ring-[var(--ring-focus)]"
|
||
: ""
|
||
}" data-template-id="${t.id}">
|
||
<div class="font-medium">${
|
||
t.name
|
||
}</div>
|
||
${
|
||
t.description
|
||
? `<div class="text-sm text-[var(--text-muted)]">${t.description}</div>`
|
||
: ""
|
||
}
|
||
${
|
||
t.is_default
|
||
? '<div class="text-xs text-[var(--text-accent)] mt-1"><i class="fas fa-star mr-1"></i>Default</div>'
|
||
: ""
|
||
}
|
||
</button>
|
||
`
|
||
)
|
||
.join("")}
|
||
</div>
|
||
<div class="mt-4 flex gap-2">
|
||
<button class="cancel-btn px-4 py-2 bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-accent-light)]">取消</button>
|
||
<button class="download-without-template-btn px-4 py-2 bg-[var(--bg-accent)] text-white rounded hover:bg-[var(--bg-accent-hover)]">Download without template</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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------------------
|
||
});
|